MessageListFragment.java revision cbdd9f78b2605e87e45e4f6761b0a8c444a8cd4c
1/* 2 * Copyright (C) 2010 The Android Open Source Project 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.email.activity; 18 19import com.android.email.Controller; 20import com.android.email.Email; 21import com.android.email.R; 22import com.android.email.RefreshManager; 23import com.android.email.Utility; 24import com.android.email.Utility.ListStateSaver; 25import com.android.email.data.MailboxAccountLoader; 26import com.android.email.provider.EmailContent; 27import com.android.email.provider.EmailContent.Account; 28import com.android.email.provider.EmailContent.Mailbox; 29import com.android.email.provider.EmailContent.Message; 30import com.android.email.provider.EmailProvider; 31import com.android.email.service.MailService; 32 33import android.app.Activity; 34import android.app.ListFragment; 35import android.app.LoaderManager; 36import android.content.ClipData; 37import android.content.ContentUris; 38import android.content.Context; 39import android.content.Loader; 40import android.content.res.Resources; 41import android.database.Cursor; 42import android.graphics.Canvas; 43import android.graphics.Point; 44import android.graphics.Typeface; 45import android.graphics.drawable.Drawable; 46import android.os.AsyncTask; 47import android.os.Bundle; 48import android.os.Parcel; 49import android.os.Parcelable; 50import android.text.TextPaint; 51import android.util.Log; 52import android.view.ActionMode; 53import android.view.DragEvent; 54import android.view.LayoutInflater; 55import android.view.Menu; 56import android.view.MenuInflater; 57import android.view.MenuItem; 58import android.view.View; 59import android.view.View.DragThumbnailBuilder; 60import android.view.View.OnDragListener; 61import android.view.ViewGroup; 62import android.widget.AdapterView; 63import android.widget.AdapterView.OnItemClickListener; 64import android.widget.AdapterView.OnItemLongClickListener; 65import android.widget.ListView; 66import android.widget.TextView; 67import android.widget.Toast; 68 69import java.security.InvalidParameterException; 70import java.util.HashSet; 71import java.util.Set; 72 73// TODO Better handling of restoring list position/adapter check status 74/** 75 * Message list. 76 * 77 * <p>This fragment uses two different loaders to load data. 78 * <ul> 79 * <li>One to load {@link Account} and {@link Mailbox}, with {@link MailboxAccountLoader}. 80 * <li>The other to actually load messages. 81 * </ul> 82 * We run them sequentially. i.e. First starts {@link MailboxAccountLoader}, and when it finishes 83 * starts the other. 84 * 85 * TODO Finalize batch move UI. Probably the "move" button should be disabled or hidden when 86 * the selection contains non-movable messages. But then how does the user know why they can't be 87 * moved? 88 */ 89public class MessageListFragment extends ListFragment 90 implements OnItemClickListener, OnItemLongClickListener, MessagesAdapter.Callback, 91 MoveMessageToDialog.Callback, OnDragListener { 92 private static final String BUNDLE_LIST_STATE = "MessageListFragment.state.listState"; 93 private static final String BUNDLE_KEY_SELECTED_MESSAGE_ID 94 = "messageListFragment.state.listState.selected_message_id"; 95 96 private static final int LOADER_ID_MAILBOX_LOADER = 1; 97 private static final int LOADER_ID_MESSAGES_LOADER = 2; 98 99 // UI Support 100 private Activity mActivity; 101 private Callback mCallback = EmptyCallback.INSTANCE; 102 103 private ListView mListView; 104 private View mListFooterView; 105 private TextView mListFooterText; 106 private View mListFooterProgress; 107 private View mListPanel; 108 private View mNoMessagesPanel; 109 110 private static final int LIST_FOOTER_MODE_NONE = 0; 111 private static final int LIST_FOOTER_MODE_MORE = 1; 112 private int mListFooterMode; 113 114 private MessagesAdapter mListAdapter; 115 116 private long mMailboxId = -1; 117 private long mLastLoadedMailboxId = -1; 118 private long mSelectedMessageId = -1; 119 120 private Account mAccount; 121 private Mailbox mMailbox; 122 private boolean mIsEasAccount; 123 private boolean mIsRefreshable; 124 private int mCountTotalAccounts; 125 126 // Controller access 127 private Controller mController; 128 private RefreshManager mRefreshManager; 129 private RefreshListener mRefreshListener = new RefreshListener(); 130 131 // Misc members 132 private boolean mDoAutoRefresh; 133 134 private boolean mOpenRequested; 135 136 /** 137 * Visibility. On XL, message list is normally visible, except when message view is shown 138 * in full-screen on portrait. 139 * 140 * When not visible, the contextual action bar will be gone. 141 */ 142 private boolean mIsVisible = true; 143 144 /** true between {@link #onResume} and {@link #onPause}. */ 145 private boolean mResumed; 146 147 /** 148 * {@link ActionMode} shown when 1 or more message is selected. 149 */ 150 private ActionMode mSelectionMode; 151 private SelectionModeCallback mLastSelectionModeCallback; 152 153 /** Whether "Send all messages" should be shown. */ 154 private boolean mShowSendCommand; 155 156 private Utility.ListStateSaver mSavedListState; 157 158 private MessageOpenTask mMessageOpenTask; 159 160 /** 161 * Callback interface that owning activities must implement 162 */ 163 public interface Callback { 164 public static final int TYPE_REGULAR = 0; 165 public static final int TYPE_DRAFT = 1; 166 public static final int TYPE_TRASH = 2; 167 168 /** 169 * Called when the specified mailbox does not exist. 170 */ 171 public void onMailboxNotFound(); 172 173 /** 174 * Called when the user wants to open a message. 175 * Note {@code mailboxId} is of the actual mailbox of the message, which is different from 176 * {@link MessageListFragment#getMailboxId} if it's magic mailboxes. 177 * 178 * @param messageId the message ID of the message 179 * @param messageMailboxId the mailbox ID of the message. 180 * This will never take values like {@link Mailbox#QUERY_ALL_INBOXES}. 181 * @param listMailboxId the mailbox ID of the listbox shown on this fragment. 182 * This can be that of a magic mailbox, e.g. {@link Mailbox#QUERY_ALL_INBOXES}. 183 * @param type {@link #TYPE_REGULAR}, {@link #TYPE_DRAFT} or {@link #TYPE_TRASH}. 184 */ 185 public void onMessageOpen(long messageId, long messageMailboxId, long listMailboxId, 186 int type); 187 188 /** 189 * Called when entering/leaving selection mode. 190 * @param enter true if entering, false if leaving 191 */ 192 public void onEnterSelectionMode(boolean enter); 193 } 194 195 private static final class EmptyCallback implements Callback { 196 public static final Callback INSTANCE = new EmptyCallback(); 197 198 @Override 199 public void onMailboxNotFound() { 200 } 201 @Override 202 public void onMessageOpen( 203 long messageId, long messageMailboxId, long listMailboxId, int type) { 204 } 205 @Override 206 public void onEnterSelectionMode(boolean enter) { 207 } 208 } 209 210 @Override 211 public void onCreate(Bundle savedInstanceState) { 212 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 213 Log.d(Email.LOG_TAG, "MessageListFragment onCreate"); 214 } 215 super.onCreate(savedInstanceState); 216 mActivity = getActivity(); 217 setHasOptionsMenu(true); 218 mController = Controller.getInstance(mActivity); 219 mRefreshManager = RefreshManager.getInstance(mActivity); 220 mRefreshManager.registerListener(mRefreshListener); 221 } 222 223 @Override 224 public View onCreateView( 225 LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { 226 // Use a custom layout, which includes the original layout with "send messages" panel. 227 View root = inflater.inflate(R.layout.message_list_fragment,null); 228 mListPanel = root.findViewById(R.id.list_panel); 229 mNoMessagesPanel = root.findViewById(R.id.no_messages_panel); 230 return root; 231 } 232 233 @Override 234 public void onActivityCreated(Bundle savedInstanceState) { 235 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 236 Log.d(Email.LOG_TAG, "MessageListFragment onActivityCreated"); 237 } 238 super.onActivityCreated(savedInstanceState); 239 240 mListView = getListView(); 241 mListView.setOnItemClickListener(this); 242 mListView.setOnItemLongClickListener(this); 243 mListView.setItemsCanFocus(false); 244 mListView.setChoiceMode(ListView.CHOICE_MODE_SINGLE); 245 246 mListAdapter = new MessagesAdapter(mActivity, this); 247 248 mListFooterView = getActivity().getLayoutInflater().inflate( 249 R.layout.message_list_item_footer, mListView, false); 250 251 if (savedInstanceState != null) { 252 // Fragment doesn't have this method. Call it manually. 253 loadState(savedInstanceState); 254 } 255 } 256 257 @Override 258 public void onStart() { 259 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 260 Log.d(Email.LOG_TAG, "MessageListFragment onStart"); 261 } 262 super.onStart(); 263 } 264 265 @Override 266 public void onResume() { 267 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 268 Log.d(Email.LOG_TAG, "MessageListFragment onResume"); 269 } 270 super.onResume(); 271 mResumed = true; 272 273 // If we're recovering from the stopped state, we don't have to reload. 274 // (when mOpenRequested = false) 275 if (mMailboxId != -1 && mOpenRequested) { 276 startLoading(); 277 } 278 } 279 280 @Override 281 public void onPause() { 282 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 283 Log.d(Email.LOG_TAG, "MessageListFragment onPause"); 284 } 285 mResumed = false; 286 super.onStop(); 287 mSavedListState = new Utility.ListStateSaver(getListView()); 288 } 289 290 @Override 291 public void onStop() { 292 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 293 Log.d(Email.LOG_TAG, "MessageListFragment onStop"); 294 } 295 super.onStop(); 296 } 297 298 @Override 299 public void onDestroy() { 300 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 301 Log.d(Email.LOG_TAG, "MessageListFragment onDestroy"); 302 } 303 Utility.cancelTaskInterrupt(mMessageOpenTask); 304 mMessageOpenTask = null; 305 mRefreshManager.unregisterListener(mRefreshListener); 306 super.onDestroy(); 307 } 308 309 @Override 310 public void onSaveInstanceState(Bundle outState) { 311 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 312 Log.d(Email.LOG_TAG, "MessageListFragment onSaveInstanceState"); 313 } 314 super.onSaveInstanceState(outState); 315 mListAdapter.onSaveInstanceState(outState); 316 outState.putParcelable(BUNDLE_LIST_STATE, new Utility.ListStateSaver(getListView())); 317 outState.putLong(BUNDLE_KEY_SELECTED_MESSAGE_ID, mSelectedMessageId); 318 } 319 320 // Unit tests use it 321 /* package */void loadState(Bundle savedInstanceState) { 322 mListAdapter.loadState(savedInstanceState); 323 mSavedListState = savedInstanceState.getParcelable(BUNDLE_LIST_STATE); 324 mSelectedMessageId = savedInstanceState.getLong(BUNDLE_KEY_SELECTED_MESSAGE_ID); 325 } 326 327 @Override 328 public void onCreateOptionsMenu(Menu menu, MenuInflater inflater) { 329 inflater.inflate(R.menu.message_list_fragment_option, menu); 330 } 331 332 @Override 333 public void onPrepareOptionsMenu(Menu menu) { 334 menu.findItem(R.id.send).setVisible(mShowSendCommand); 335 } 336 337 @Override 338 public boolean onOptionsItemSelected(MenuItem item) { 339 switch (item.getItemId()) { 340 case R.id.send: 341 onSendPendingMessages(); 342 return true; 343 344 } 345 return false; 346 } 347 348 public void setCallback(Callback callback) { 349 mCallback = (callback != null) ? callback : EmptyCallback.INSTANCE; 350 } 351 352 public void setVisibility(boolean isVisible) { 353 if (isVisible == mIsVisible) { 354 return; 355 } 356 mIsVisible = isVisible; 357 updateSelectionMode(); 358 } 359 360 /** 361 * Clear all the content, stop the loaders, etc -- should be called when the fragment is hidden. 362 */ 363 public void clearContent() { 364 mMailboxId = -1; 365 stopLoaders(); 366 onDeselectAll(); 367 if (mListAdapter != null) { 368 mListAdapter.swapCursor(null); 369 } 370 setListShownNoAnimation(false); 371 } 372 373 /** 374 * Called by an Activity to open an mailbox. 375 * 376 * @param mailboxId the ID of a mailbox, or one of "special" mailbox IDs like 377 * {@link Mailbox#QUERY_ALL_INBOXES}. -1 is not allowed. 378 */ 379 public void openMailbox(long mailboxId) { 380 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 381 Log.d(Email.LOG_TAG, "MessageListFragment openMailbox"); 382 } 383 if (mailboxId == -1) { 384 throw new InvalidParameterException(); 385 } 386 if (mMailboxId == mailboxId) { 387 return; 388 } 389 390 mOpenRequested = true; 391 mMailboxId = mailboxId; 392 393 onDeselectAll(); 394 if (mResumed) { 395 startLoading(); 396 } 397 } 398 399 public void setSelectedMessage(long messageId) { 400 mSelectedMessageId = messageId; 401 if (mResumed) { 402 highlightSelectedMessage(true); 403 } 404 } 405 406 /* package */MessagesAdapter getAdapterForTest() { 407 return mListAdapter; 408 } 409 410 /** 411 * @return the account id or -1 if it's unknown yet. It's also -1 if it's a magic mailbox. 412 */ 413 public long getAccountId() { 414 return (mMailbox == null) ? -1 : mMailbox.mAccountKey; 415 } 416 417 /** 418 * @return the mailbox id, which is the value set to {@link #openMailbox}. 419 * (Meaning it will never return -1, but may return special values, 420 * eg {@link Mailbox#QUERY_ALL_INBOXES}). 421 */ 422 public long getMailboxId() { 423 return mMailboxId; 424 } 425 426 /** 427 * @return true if the mailbox is a "special" box. (e.g. combined inbox, all starred, etc.) 428 */ 429 public boolean isMagicMailbox() { 430 return mMailboxId < 0; 431 } 432 433 /** 434 * @return the number of messages that are currently selecteed. 435 */ 436 public int getSelectedCount() { 437 return mListAdapter.getSelectedSet().size(); 438 } 439 440 /** 441 * @return true if the list is in the "selection" mode. 442 */ 443 public boolean isInSelectionMode() { 444 return mSelectionMode != null; 445 } 446 447 /** 448 * Called when a message is clicked. 449 */ 450 public void onItemClick(AdapterView<?> parent, View view, int position, long id) { 451 if (view != mListFooterView) { 452 MessageListItem itemView = (MessageListItem) view; 453 onMessageOpen(itemView.mMailboxId, id); 454 } else { 455 doFooterClick(); 456 } 457 } 458 459 // This is tentative drag & drop UI 460 // STOPSHIP this entire class needs to be rewritten based on the actual UI design 461 private static class ThumbnailBuilder extends DragThumbnailBuilder { 462 private static Drawable sBackground; 463 private static TextPaint sPaint; 464 465 // TODO Get actual dimention from UI 466 private static final int mWidth = 250; 467 private final int mHeight; 468 private String mDragDesc; 469 private float mDragDescX; 470 private float mDragDescY; 471 472 public ThumbnailBuilder(View view, int count) { 473 super(view); 474 Resources resources = view.getResources(); 475 // TODO Get actual dimension from UI 476 mHeight = view.getHeight(); 477 mDragDesc = resources.getQuantityString(R.plurals.move_messages, count, count); 478 mDragDescX = 60; 479 // Use height of this font?? 480 mDragDescY = view.getHeight() / 2; 481 if (sBackground == null) { 482 sBackground = resources.getDrawable(R.drawable.drag_background_holo); 483 sBackground.setBounds(0, 0, mWidth, view.getHeight()); 484 sPaint = new TextPaint(); 485 sPaint.setTypeface(Typeface.DEFAULT_BOLD); 486 sPaint.setTextSize(18); 487 } 488 } 489 490 @Override 491 public void onProvideThumbnailMetrics(Point thumbnailSize, Point thumbnailTouchPoint) { 492 thumbnailSize.set(mWidth, mHeight); 493 thumbnailTouchPoint.set(20, mHeight / 2); 494 } 495 496 @Override 497 public void onDrawThumbnail(Canvas canvas) { 498 super.onDrawThumbnail(canvas); 499 sBackground.draw(canvas); 500 canvas.drawText(mDragDesc, mDragDescX, mDragDescY, sPaint); 501 } 502 } 503 504 public boolean onDrag(View view, DragEvent event) { 505 switch(event.getAction()) { 506 case DragEvent.ACTION_DRAG_ENDED: 507 if (event.getResult()) { 508 onDeselectAll(); // Clear the selection 509 } 510 break; 511 } 512 return false; 513 } 514 515 public boolean onItemLongClick(AdapterView<?> parent, View view, int position, long id) { 516 if (view != mListFooterView) { 517 // We can't move from combined accounts view 518 // We also need to check the actual mailbox to see if we can move items from it 519 if (mAccount == null || mMailbox == null) { 520 return false; 521 } else if (mMailboxId > 0 && !Mailbox.canMoveFrom(mActivity, mMailboxId)) { 522 return false; 523 } 524 MessageListItem listItem = (MessageListItem)view; 525 if (!mListAdapter.isSelected(listItem)) { 526 toggleSelection(listItem); 527 } 528 // Create ClipData with the Uri of the message we're long clicking 529 ClipData data = ClipData.newUri(mActivity.getContentResolver(), 530 MessageListItem.MESSAGE_LIST_ITEMS_CLIP_LABEL, null, 531 Message.CONTENT_URI.buildUpon() 532 .appendPath(Long.toString(listItem.mMessageId)) 533 .appendQueryParameter( 534 EmailProvider.MESSAGE_URI_PARAMETER_MAILBOX_ID, 535 Long.toString(mMailboxId)) 536 .build()); 537 Set<Long> selectedMessageIds = mListAdapter.getSelectedSet(); 538 int size = selectedMessageIds.size(); 539 // Add additional Uri's for any other selected messages 540 for (Long messageId: selectedMessageIds) { 541 if (messageId.longValue() != listItem.mMessageId) { 542 data.addItem(new ClipData.Item( 543 ContentUris.withAppendedId(Message.CONTENT_URI, messageId))); 544 } 545 } 546 // Start dragging now 547 listItem.setOnDragListener(this); 548 listItem.startDrag(data, new ThumbnailBuilder(listItem, size), false, null); 549 return true; 550 } 551 return false; 552 } 553 554 private void toggleSelection(MessageListItem itemView) { 555 mListAdapter.toggleSelected(itemView); 556 } 557 558 /** 559 * Called when a message on the list is selected 560 * 561 * @param messageMailboxId the actual mailbox ID of the message. Note it's different from 562 * {@link #mMailboxId} in combined mailboxes. ({@link #mMailboxId} can take values such as 563 * {@link Mailbox#QUERY_ALL_INBOXES}) 564 * @param messageId ID of the msesage to open. 565 */ 566 private void onMessageOpen(final long messageMailboxId, final long messageId) { 567 Utility.cancelTaskInterrupt(mMessageOpenTask); 568 mMessageOpenTask = new MessageOpenTask(messageMailboxId, messageId); 569 mMessageOpenTask.execute(); 570 } 571 572 /** 573 * Task to look up the mailbox type for a message, and kicks the callback. 574 */ 575 private class MessageOpenTask extends AsyncTask<Void, Void, Integer> { 576 private final long mMessageMailboxId; 577 private final long mMessageId; 578 579 public MessageOpenTask(long messageMailboxId, long messageId) { 580 mMessageMailboxId = messageMailboxId; 581 mMessageId = messageId; 582 } 583 584 @Override 585 protected Integer doInBackground(Void... params) { 586 // Restore the mailbox type. Note we can't use mMailbox.mType here, because 587 // we don't have mMailbox for combined mailbox. 588 // ("All Starred" can contain any kind of messages.) 589 switch (Mailbox.getMailboxType(mActivity, mMessageMailboxId)) { 590 case EmailContent.Mailbox.TYPE_DRAFTS: 591 return Callback.TYPE_DRAFT; 592 case EmailContent.Mailbox.TYPE_TRASH: 593 return Callback.TYPE_TRASH; 594 default: 595 return Callback.TYPE_REGULAR; 596 } 597 } 598 599 @Override 600 protected void onPostExecute(Integer type) { 601 if (isCancelled() || type == null) { 602 return; 603 } 604 mCallback.onMessageOpen(mMessageId, mMessageMailboxId, getMailboxId(), type); 605 } 606 } 607 608 public void onMultiToggleRead() { 609 onMultiToggleRead(mListAdapter.getSelectedSet()); 610 } 611 612 public void onMultiToggleFavorite() { 613 onMultiToggleFavorite(mListAdapter.getSelectedSet()); 614 } 615 616 public void onMultiDelete() { 617 onMultiDelete(mListAdapter.getSelectedSet()); 618 } 619 620 public void onMultiMove() { 621 long[] messageIds = Utility.toPrimitiveLongArray(mListAdapter.getSelectedSet()); 622 MoveMessageToDialog dialog = MoveMessageToDialog.newInstance(getActivity(), messageIds, 623 this); 624 dialog.show(getFragmentManager(), "dialog"); 625 } 626 627 @Override 628 public void onMoveToMailboxSelected(long newMailboxId, long[] messageIds) { 629 ActivityHelper.moveMessages(getActivity(), newMailboxId, messageIds); 630 631 // Move is async, so we can't refresh now. Instead, just clear the selection. 632 onDeselectAll(); 633 } 634 635 /** 636 * Refresh the list. NOOP for special mailboxes (e.g. combined inbox). 637 * 638 * Note: Manual refresh is enabled even for push accounts. 639 */ 640 public void onRefresh(boolean userRequest) { 641 if (!mIsRefreshable) { 642 return; 643 } 644 long accountId = getAccountId(); 645 if (accountId != -1) { 646 mRefreshManager.refreshMessageList(accountId, mMailboxId, userRequest); 647 } 648 } 649 650 public void onDeselectAll() { 651 if ((mListAdapter == null) || (mListAdapter.getSelectedSet().size() == 0)) { 652 return; 653 } 654 mListAdapter.getSelectedSet().clear(); 655 getListView().invalidateViews(); 656 if (isInSelectionMode()) { 657 finishSelectionMode(); 658 } 659 } 660 661 /** 662 * Load more messages. NOOP for special mailboxes (e.g. combined inbox). 663 */ 664 private void onLoadMoreMessages() { 665 long accountId = getAccountId(); 666 if (accountId != -1) { 667 mRefreshManager.loadMoreMessages(accountId, mMailboxId); 668 } 669 } 670 671 /** 672 * @return if it's an outbox or "all outboxes". 673 * 674 * TODO make it private. It's only used by MessageList, but the callsite is obsolete. 675 */ 676 public boolean isOutbox() { 677 return (getMailboxId() == Mailbox.QUERY_ALL_OUTBOX) 678 || ((mMailbox != null) && (mMailbox.mType == Mailbox.TYPE_OUTBOX)); 679 } 680 681 public void onSendPendingMessages() { 682 RefreshManager rm = RefreshManager.getInstance(mActivity); 683 if (getMailboxId() == Mailbox.QUERY_ALL_OUTBOX) { 684 rm.sendPendingMessagesForAllAccounts(); 685 } else if (mMailbox != null) { // Magic boxes don't have a specific account id. 686 rm.sendPendingMessages(mMailbox.mAccountKey); 687 } 688 } 689 690 private void onSetMessageRead(long messageId, boolean newRead) { 691 mController.setMessageRead(messageId, newRead); 692 } 693 694 private void onSetMessageFavorite(long messageId, boolean newFavorite) { 695 mController.setMessageFavorite(messageId, newFavorite); 696 } 697 698 /** 699 * Toggles a set read/unread states. Note, the default behavior is "mark unread", so the 700 * sense of the helper methods is "true=unread". 701 * 702 * @param selectedSet The current list of selected items 703 */ 704 private void onMultiToggleRead(Set<Long> selectedSet) { 705 toggleMultiple(selectedSet, new MultiToggleHelper() { 706 707 public boolean getField(long messageId, Cursor c) { 708 return c.getInt(MessagesAdapter.COLUMN_READ) == 0; 709 } 710 711 public boolean setField(long messageId, Cursor c, boolean newValue) { 712 boolean oldValue = getField(messageId, c); 713 if (oldValue != newValue) { 714 onSetMessageRead(messageId, !newValue); 715 return true; 716 } 717 return false; 718 } 719 }); 720 } 721 722 /** 723 * Toggles a set of favorites (stars) 724 * 725 * @param selectedSet The current list of selected items 726 */ 727 private void onMultiToggleFavorite(Set<Long> selectedSet) { 728 toggleMultiple(selectedSet, new MultiToggleHelper() { 729 730 public boolean getField(long messageId, Cursor c) { 731 return c.getInt(MessagesAdapter.COLUMN_FAVORITE) != 0; 732 } 733 734 public boolean setField(long messageId, Cursor c, boolean newValue) { 735 boolean oldValue = getField(messageId, c); 736 if (oldValue != newValue) { 737 onSetMessageFavorite(messageId, newValue); 738 return true; 739 } 740 return false; 741 } 742 }); 743 } 744 745 private void onMultiDelete(Set<Long> selectedSet) { 746 // Clone the set, because deleting is going to thrash things 747 HashSet<Long> cloneSet = new HashSet<Long>(selectedSet); 748 for (Long id : cloneSet) { 749 mController.deleteMessage(id, -1); 750 } 751 Toast.makeText(mActivity, mActivity.getResources().getQuantityString( 752 R.plurals.message_deleted_toast, cloneSet.size()), Toast.LENGTH_SHORT).show(); 753 selectedSet.clear(); 754 // Message deletion is async... Can't refresh the list immediately. 755 } 756 757 private interface MultiToggleHelper { 758 /** 759 * Return true if the field of interest is "set". If one or more are false, then our 760 * bulk action will be to "set". If all are set, our bulk action will be to "clear". 761 * @param messageId the message id of the current message 762 * @param c the cursor, positioned to the item of interest 763 * @return true if the field at this row is "set" 764 */ 765 public boolean getField(long messageId, Cursor c); 766 767 /** 768 * Set or clear the field of interest. Return true if a change was made. 769 * @param messageId the message id of the current message 770 * @param c the cursor, positioned to the item of interest 771 * @param newValue the new value to be set at this row 772 * @return true if a change was actually made 773 */ 774 public boolean setField(long messageId, Cursor c, boolean newValue); 775 } 776 777 /** 778 * Toggle multiple fields in a message, using the following logic: If one or more fields 779 * are "clear", then "set" them. If all fields are "set", then "clear" them all. 780 * 781 * @param selectedSet the set of messages that are selected 782 * @param helper functions to implement the specific getter & setter 783 * @return the number of messages that were updated 784 */ 785 private int toggleMultiple(Set<Long> selectedSet, MultiToggleHelper helper) { 786 Cursor c = mListAdapter.getCursor(); 787 boolean anyWereFound = false; 788 boolean allWereSet = true; 789 790 c.moveToPosition(-1); 791 while (c.moveToNext()) { 792 long id = c.getInt(MessagesAdapter.COLUMN_ID); 793 if (selectedSet.contains(Long.valueOf(id))) { 794 anyWereFound = true; 795 if (!helper.getField(id, c)) { 796 allWereSet = false; 797 break; 798 } 799 } 800 } 801 802 int numChanged = 0; 803 804 if (anyWereFound) { 805 boolean newValue = !allWereSet; 806 c.moveToPosition(-1); 807 while (c.moveToNext()) { 808 long id = c.getInt(MessagesAdapter.COLUMN_ID); 809 if (selectedSet.contains(Long.valueOf(id))) { 810 if (helper.setField(id, c, newValue)) { 811 ++numChanged; 812 } 813 } 814 } 815 } 816 817 refreshList(); 818 819 return numChanged; 820 } 821 822 /** 823 * Test selected messages for showing appropriate labels 824 * @param selectedSet 825 * @param column_id 826 * @param defaultflag 827 * @return true when the specified flagged message is selected 828 */ 829 private boolean testMultiple(Set<Long> selectedSet, int column_id, boolean defaultflag) { 830 Cursor c = mListAdapter.getCursor(); 831 if (c == null || c.isClosed()) { 832 return false; 833 } 834 c.moveToPosition(-1); 835 while (c.moveToNext()) { 836 long id = c.getInt(MessagesAdapter.COLUMN_ID); 837 if (selectedSet.contains(Long.valueOf(id))) { 838 if (c.getInt(column_id) == (defaultflag ? 1 : 0)) { 839 return true; 840 } 841 } 842 } 843 return false; 844 } 845 846 /** 847 * @return true if one or more non-starred messages are selected. 848 */ 849 public boolean doesSelectionContainNonStarredMessage() { 850 return testMultiple(mListAdapter.getSelectedSet(), MessagesAdapter.COLUMN_FAVORITE, 851 false); 852 } 853 854 /** 855 * @return true if one or more read messages are selected. 856 */ 857 public boolean doesSelectionContainReadMessage() { 858 return testMultiple(mListAdapter.getSelectedSet(), MessagesAdapter.COLUMN_READ, true); 859 } 860 861 /** 862 * Called by activity to indicate that the user explicitly opened the 863 * mailbox and it needs auto-refresh when it's first shown. TODO: 864 * {@link MessageList} needs to call this as well. 865 * 866 * TODO It's a bit ugly. We can remove this if this fragment "remembers" the current mailbox ID 867 * through configuration changes. 868 */ 869 public void doAutoRefresh() { 870 mDoAutoRefresh = true; 871 } 872 873 /** 874 * Implements a timed refresh of "stale" mailboxes. This should only happen when 875 * multiple conditions are true, including: 876 * Only refreshable mailboxes. 877 * Only when the user explicitly opens the mailbox (not onResume, for example) 878 * Only when the mailbox is "stale" (currently set to 5 minutes since last refresh) 879 * Note we do this even if it's a push account; even on Exchange only inbox can be pushed. 880 */ 881 private void autoRefreshStaleMailbox() { 882 if (!mDoAutoRefresh // Not explicitly open 883 || !mIsRefreshable // Not refreshable (special box such as drafts, or magic boxes) 884 ) { 885 return; 886 } 887 mDoAutoRefresh = false; 888 if (!mRefreshManager.isMailboxStale(mMailboxId)) { 889 return; 890 } 891 onRefresh(false); 892 } 893 894 /** Implements {@link MessagesAdapter.Callback} */ 895 @Override 896 public void onAdapterFavoriteChanged(MessageListItem itemView, boolean newFavorite) { 897 onSetMessageFavorite(itemView.mMessageId, newFavorite); 898 } 899 900 /** Implements {@link MessagesAdapter.Callback} */ 901 @Override 902 public void onAdapterSelectedChanged( 903 MessageListItem itemView, boolean newSelected, int mSelectedCount) { 904 updateSelectionMode(); 905 } 906 907 private void determineFooterMode() { 908 mListFooterMode = LIST_FOOTER_MODE_NONE; 909 if ((mMailbox == null) || (mMailbox.mType == Mailbox.TYPE_OUTBOX) 910 || (mMailbox.mType == Mailbox.TYPE_DRAFTS)) { 911 return; // No footer 912 } 913 if (!mIsEasAccount) { 914 // IMAP, POP has "load more" 915 mListFooterMode = LIST_FOOTER_MODE_MORE; 916 } 917 } 918 919 private void addFooterView() { 920 ListView lv = getListView(); 921 if (mListFooterView != null) { 922 lv.removeFooterView(mListFooterView); 923 } 924 determineFooterMode(); 925 if (mListFooterMode != LIST_FOOTER_MODE_NONE) { 926 927 lv.addFooterView(mListFooterView); 928 lv.setAdapter(mListAdapter); 929 930 mListFooterProgress = mListFooterView.findViewById(R.id.progress); 931 mListFooterText = (TextView) mListFooterView.findViewById(R.id.main_text); 932 933 updateListFooter(); 934 } 935 } 936 937 /** 938 * Set the list footer text based on mode and the current "network active" status 939 */ 940 private void updateListFooter() { 941 if (mListFooterMode != LIST_FOOTER_MODE_NONE) { 942 int footerTextId = 0; 943 switch (mListFooterMode) { 944 case LIST_FOOTER_MODE_MORE: 945 boolean active = mRefreshManager.isMessageListRefreshing(mMailboxId); 946 footerTextId = active ? R.string.status_loading_messages 947 : R.string.message_list_load_more_messages_action; 948 mListFooterProgress.setVisibility(active ? View.VISIBLE : View.GONE); 949 break; 950 } 951 mListFooterText.setText(footerTextId); 952 } 953 } 954 955 /** 956 * Handle a click in the list footer, which changes meaning depending on what we're looking at. 957 */ 958 private void doFooterClick() { 959 switch (mListFooterMode) { 960 case LIST_FOOTER_MODE_NONE: // should never happen 961 break; 962 case LIST_FOOTER_MODE_MORE: 963 onLoadMoreMessages(); 964 break; 965 } 966 } 967 968 private void showSendCommand(boolean show) { 969 mShowSendCommand = show; 970 mActivity.invalidateOptionsMenu(); 971 } 972 973 private void showSendCommandIfNecessary() { 974 showSendCommand(isOutbox() && (mListAdapter != null) && (mListAdapter.getCount() > 0)); 975 } 976 977 private void showNoMessageText(boolean visible) { 978 mNoMessagesPanel.setVisibility(visible ? View.VISIBLE : View.GONE); 979 mListPanel.setVisibility(visible ? View.GONE : View.VISIBLE); 980 } 981 982 private void showNoMessageTextIfNecessary() { 983 boolean noItem = (mListFooterMode == LIST_FOOTER_MODE_NONE) 984 && (mListView.getCount() == 0); 985 showNoMessageText(noItem); 986 } 987 988 private void startLoading() { 989 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 990 Log.d(Email.LOG_TAG, "MessageListFragment startLoading"); 991 } 992 mOpenRequested = false; 993 994 // Clear the list. (ListFragment will show the "Loading" animation) 995 showNoMessageText(false); 996 setListShown(false); 997 showSendCommand(false); 998 999 // Start loading... 1000 final LoaderManager lm = getLoaderManager(); 1001 1002 // If we're loading a different mailbox, discard the previous result. 1003 // It also causes not to preserve the list position. 1004 boolean mailboxChanging = false; 1005 if ((mLastLoadedMailboxId != -1) && (mLastLoadedMailboxId != mMailboxId)) { 1006 mailboxChanging = true; 1007 stopLoaders(); 1008 } 1009 lm.initLoader(LOADER_ID_MAILBOX_LOADER, null, 1010 new MailboxAccountLoaderCallback(mailboxChanging)); 1011 } 1012 1013 private void stopLoaders() { 1014 final LoaderManager lm = getLoaderManager(); 1015 lm.destroyLoader(LOADER_ID_MAILBOX_LOADER); 1016 lm.destroyLoader(LOADER_ID_MESSAGES_LOADER); 1017 } 1018 1019 /** 1020 * Loader callbacks for {@link MailboxAccountLoader}. 1021 */ 1022 private class MailboxAccountLoaderCallback implements LoaderManager.LoaderCallbacks< 1023 MailboxAccountLoader.Result> { 1024 private boolean mMailboxChanging; 1025 1026 public MailboxAccountLoaderCallback(boolean mailboxChanging) { 1027 mMailboxChanging = mailboxChanging; 1028 } 1029 1030 @Override 1031 public Loader<MailboxAccountLoader.Result> onCreateLoader(int id, Bundle args) { 1032 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 1033 Log.d(Email.LOG_TAG, 1034 "MessageListFragment onCreateLoader(mailbox) mailboxId=" + mMailboxId); 1035 } 1036 return new MailboxAccountLoader(getActivity().getApplicationContext(), mMailboxId); 1037 } 1038 1039 @Override 1040 public void onLoadFinished(Loader<MailboxAccountLoader.Result> loader, 1041 MailboxAccountLoader.Result result) { 1042 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 1043 Log.d(Email.LOG_TAG, "MessageListFragment onLoadFinished(mailbox) mailboxId=" 1044 + mMailboxId); 1045 } 1046 if (!result.mIsFound) { 1047 mCallback.onMailboxNotFound(); 1048 return; 1049 } 1050 1051 mLastLoadedMailboxId = mMailboxId; 1052 mAccount = result.mAccount; 1053 mMailbox = result.mMailbox; 1054 mIsEasAccount = result.mIsEasAccount; 1055 mIsRefreshable = result.mIsRefreshable; 1056 mCountTotalAccounts = result.mCountTotalAccounts; 1057 getLoaderManager().initLoader(LOADER_ID_MESSAGES_LOADER, null, 1058 new MessagesLoaderCallback(mMailboxChanging)); 1059 1060 // Clear this for next reload triggered by content changed events. 1061 mMailboxChanging = false; 1062 } 1063 1064 @Override 1065 public void onLoaderReset(Loader<MailboxAccountLoader.Result> loader) { 1066 } 1067 } 1068 1069 /** 1070 * Reload the data and refresh the list view. 1071 */ 1072 private void refreshList() { 1073 getLoaderManager().restartLoader(LOADER_ID_MESSAGES_LOADER, null, 1074 new MessagesLoaderCallback(false)); 1075 } 1076 1077 /** 1078 * Loader callbacks for message list. 1079 */ 1080 private class MessagesLoaderCallback implements LoaderManager.LoaderCallbacks<Cursor> { 1081 private boolean mMailboxChanging; 1082 1083 public MessagesLoaderCallback(boolean mailboxChanging) { 1084 mMailboxChanging = mailboxChanging; 1085 } 1086 1087 @Override 1088 public Loader<Cursor> onCreateLoader(int id, Bundle args) { 1089 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 1090 Log.d(Email.LOG_TAG, 1091 "MessageListFragment onCreateLoader(messages) mailboxId=" + mMailboxId); 1092 } 1093 return MessagesAdapter.createLoader(getActivity(), mMailboxId); 1094 } 1095 1096 @Override 1097 public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) { 1098 if (Email.DEBUG_LIFECYCLE && Email.DEBUG) { 1099 Log.d(Email.LOG_TAG, 1100 "MessageListFragment onLoadFinished(messages) mailboxId=" + mMailboxId); 1101 } 1102 1103 // Save list view state (primarily scroll position) 1104 final ListView lv = getListView(); 1105 final Utility.ListStateSaver lss; 1106 if (mMailboxChanging) { 1107 lss = null; // Don't preserve list state 1108 } else if (mSavedListState != null) { 1109 lss = mSavedListState; 1110 mSavedListState = null; 1111 } else { 1112 lss = new Utility.ListStateSaver(lv); 1113 } 1114 1115 // Update the list 1116 mListAdapter.swapCursor(cursor); 1117 // Show chips if combined view. 1118 mListAdapter.setShowColorChips(mMailboxId < 0 && mCountTotalAccounts > 1); 1119 setListAdapter(mListAdapter); 1120 setListShown(true); 1121 1122 // Various post processing... 1123 autoRefreshStaleMailbox(); 1124 addFooterView(); 1125 updateSelectionMode(); 1126 showSendCommandIfNecessary(); 1127 showNoMessageTextIfNecessary(); 1128 1129 // We want to make selection visible only when the loader was explicitly started. 1130 // i.e. Refresh caused by content changed events shouldn't scroll the list. 1131 highlightSelectedMessage(mMailboxChanging); 1132 1133 // Restore the state -- this step has to be the last, because Some of the 1134 // "post processing" seems to reset the scroll position. 1135 if (lss != null) { 1136 lss.restore(lv); 1137 } 1138 1139 resetNewMessageCount(mActivity, mMailboxId, getAccountId()); 1140 1141 // Clear this for next reload triggered by content changed events. 1142 mMailboxChanging = false; 1143 } 1144 1145 @Override 1146 public void onLoaderReset(Loader<Cursor> loader) { 1147 mListAdapter.swapCursor(null); 1148 } 1149 } 1150 1151 /** 1152 * Reset the "new message" count. 1153 * <ul> 1154 * <li>If {@code mailboxId} is {@link Mailbox#QUERY_ALL_INBOXES}, reset the 1155 * counts of all accounts. 1156 * <li>If {@code mailboxId} is not of a magic inbox (i.e. >= 0) and {@code 1157 * accountId} is valid, reset the count of the specified account. 1158 * </ul> 1159 */ 1160 /* protected */static void resetNewMessageCount( 1161 Context context, long mailboxId, long accountId) { 1162 if (mailboxId == Mailbox.QUERY_ALL_INBOXES) { 1163 MailService.resetNewMessageCount(context, -1); 1164 } else if (mailboxId >= 0 && accountId != -1) { 1165 MailService.resetNewMessageCount(context, accountId); 1166 } 1167 } 1168 1169 /** 1170 * Show/hide the "selection" action mode, according to the number of selected messages and 1171 * the visibility of the fragment. 1172 * Also update the content (title and menus) if necessary. 1173 */ 1174 public void updateSelectionMode() { 1175 final int numSelected = getSelectedCount(); 1176 if ((numSelected == 0) || !mIsVisible) { 1177 finishSelectionMode(); 1178 return; 1179 } 1180 if (isInSelectionMode()) { 1181 updateSelectionModeView(); 1182 } else { 1183 mLastSelectionModeCallback = new SelectionModeCallback(); 1184 getActivity().startActionMode(mLastSelectionModeCallback); 1185 } 1186 } 1187 1188 1189 /** 1190 * Finish the "selection" action mode. 1191 * 1192 * Note this method finishes the contextual mode, but does *not* clear the selection. 1193 * If you want to do so use {@link #onDeselectAll()} instead. 1194 */ 1195 private void finishSelectionMode() { 1196 if (isInSelectionMode()) { 1197 mLastSelectionModeCallback.mClosedByUser = false; 1198 mSelectionMode.finish(); 1199 } 1200 } 1201 1202 /** Update the "selection" action mode bar */ 1203 private void updateSelectionModeView() { 1204 mSelectionMode.invalidate(); 1205 } 1206 1207 private class SelectionModeCallback implements ActionMode.Callback { 1208 private MenuItem mMarkRead; 1209 private MenuItem mMarkUnread; 1210 private MenuItem mAddStar; 1211 private MenuItem mRemoveStar; 1212 1213 /* package */ boolean mClosedByUser = true; 1214 1215 @Override 1216 public boolean onCreateActionMode(ActionMode mode, Menu menu) { 1217 mSelectionMode = mode; 1218 1219 MenuInflater inflater = getActivity().getMenuInflater(); 1220 inflater.inflate(R.menu.message_list_selection_mode, menu); 1221 mMarkRead = menu.findItem(R.id.mark_read); 1222 mMarkUnread = menu.findItem(R.id.mark_unread); 1223 mAddStar = menu.findItem(R.id.add_star); 1224 mRemoveStar = menu.findItem(R.id.remove_star); 1225 1226 mCallback.onEnterSelectionMode(true); 1227 return true; 1228 } 1229 1230 @Override 1231 public boolean onPrepareActionMode(ActionMode mode, Menu menu) { 1232 int num = getSelectedCount(); 1233 // Set title -- "# selected" 1234 mSelectionMode.setTitle(getActivity().getResources().getQuantityString( 1235 R.plurals.message_view_selected_message_count, num, num)); 1236 1237 // Show appropriate menu items. 1238 boolean nonStarExists = doesSelectionContainNonStarredMessage(); 1239 boolean readExists = doesSelectionContainReadMessage(); 1240 mMarkRead.setVisible(!readExists); 1241 mMarkUnread.setVisible(readExists); 1242 mAddStar.setVisible(nonStarExists); 1243 mRemoveStar.setVisible(!nonStarExists); 1244 return true; 1245 } 1246 1247 @Override 1248 public boolean onActionItemClicked(ActionMode mode, MenuItem item) { 1249 switch (item.getItemId()) { 1250 case R.id.mark_read: 1251 case R.id.mark_unread: 1252 onMultiToggleRead(); 1253 break; 1254 case R.id.add_star: 1255 case R.id.remove_star: 1256 onMultiToggleFavorite(); 1257 break; 1258 case R.id.delete: 1259 onMultiDelete(); 1260 break; 1261 case R.id.move: 1262 onMultiMove(); 1263 break; 1264 } 1265 return true; 1266 } 1267 1268 @Override 1269 public void onDestroyActionMode(ActionMode mode) { 1270 mCallback.onEnterSelectionMode(false); 1271 1272 // Clear this before onDeselectAll() to prevent onDeselectAll() from trying to close the 1273 // contextual mode again. 1274 mSelectionMode = null; 1275 if (mClosedByUser) { 1276 // Clear selection, only when the contextual mode is explicitly closed by the user. 1277 // 1278 // We close the contextual mode when the fragment becomes temporary invisible 1279 // (i.e. mIsVisible == false) too, in which case we want to keep the selection. 1280 onDeselectAll(); 1281 } 1282 } 1283 } 1284 1285 private class RefreshListener implements RefreshManager.Listener { 1286 @Override 1287 public void onMessagingError(long accountId, long mailboxId, String message) { 1288 } 1289 1290 @Override 1291 public void onRefreshStatusChanged(long accountId, long mailboxId) { 1292 updateListFooter(); 1293 } 1294 } 1295 1296 /** 1297 * Object that holds the current state (right now it's only the ListView state) of the fragment. 1298 * 1299 * Used by {@link MessageListXLFragmentManager} to preserve scroll position through fragment 1300 * transitions. 1301 */ 1302 public static class State implements Parcelable { 1303 private final ListStateSaver mListState; 1304 1305 private State(Parcel p) { 1306 mListState = p.readParcelable(getClass().getClassLoader()); 1307 } 1308 1309 private State(MessageListFragment messageListFragment) { 1310 mListState = new Utility.ListStateSaver(messageListFragment.getListView()); 1311 } 1312 1313 public void restore(MessageListFragment messageListFragment) { 1314 messageListFragment.mSavedListState = mListState; 1315 } 1316 1317 @Override 1318 public int describeContents() { 1319 return 0; 1320 } 1321 1322 @Override 1323 public void writeToParcel(Parcel dest, int flags) { 1324 dest.writeParcelable(mListState, flags); 1325 } 1326 1327 public static final Parcelable.Creator<State> CREATOR 1328 = new Parcelable.Creator<State>() { 1329 public State createFromParcel(Parcel in) { 1330 return new State(in); 1331 } 1332 1333 public State[] newArray(int size) { 1334 return new State[size]; 1335 } 1336 }; 1337 } 1338 1339 public State getState() { 1340 return new State(this); 1341 } 1342 1343 /** 1344 * Highlight the selected message. 1345 */ 1346 private void highlightSelectedMessage(boolean ensureSelectionVisible) { 1347 if (mSelectedMessageId == -1) { 1348 // No mailbox selected 1349 mListView.clearChoices(); 1350 return; 1351 } 1352 1353 final int count = mListView.getCount(); 1354 for (int i = 0; i < count; i++) { 1355 if (mListView.getItemIdAtPosition(i) != mSelectedMessageId) { 1356 continue; 1357 } 1358 mListView.setItemChecked(i, true); 1359 if (ensureSelectionVisible) { 1360 Utility.listViewSmoothScrollToPosition(getActivity(), mListView, i); 1361 } 1362 break; 1363 } 1364 } 1365} 1366