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