1 /* 2 * Copyright (C) 2013 Google Inc. 3 * Licensed to The Android Open Source Project. 4 * 5 * Licensed under the Apache License, Version 2.0 (the "License"); 6 * you may not use this file except in compliance with the License. 7 * You may obtain a copy of the License at 8 * 9 * http://www.apache.org/licenses/LICENSE-2.0 10 * 11 * Unless required by applicable law or agreed to in writing, software 12 * distributed under the License is distributed on an "AS IS" BASIS, 13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 * See the License for the specific language governing permissions and 15 * limitations under the License. 16 */ 17 package com.android.mail.ui; 18 19 import android.app.LoaderManager; 20 import android.app.LoaderManager.LoaderCallbacks; 21 import android.content.Context; 22 import android.content.Loader; 23 import android.content.res.Resources; 24 import android.graphics.Color; 25 import android.net.Uri; 26 import android.os.Bundle; 27 import android.support.v4.text.BidiFormatter; 28 import android.support.v4.util.SparseArrayCompat; 29 import android.text.TextUtils; 30 import android.util.AttributeSet; 31 import android.view.LayoutInflater; 32 import android.view.View; 33 import android.view.ViewGroup; 34 import android.widget.ImageView; 35 import android.widget.LinearLayout; 36 import android.widget.TextView; 37 38 import com.android.emailcommon.mail.Address; 39 import com.android.mail.R; 40 import com.android.mail.browse.ConversationCursor; 41 import com.android.mail.content.ObjectCursor; 42 import com.android.mail.content.ObjectCursorLoader; 43 import com.android.mail.providers.Account; 44 import com.android.mail.providers.Conversation; 45 import com.android.mail.providers.Folder; 46 import com.android.mail.providers.ParticipantInfo; 47 import com.android.mail.providers.UIProvider; 48 import com.android.mail.providers.UIProvider.AccountCapabilities; 49 import com.android.mail.providers.UIProvider.ConversationListQueryParameters; 50 import com.android.mail.utils.LogUtils; 51 import com.android.mail.utils.Utils; 52 import com.google.common.collect.ImmutableList; 53 import com.google.common.collect.ImmutableSortedSet; 54 import com.google.common.collect.Lists; 55 import com.google.common.collect.Maps; 56 57 import java.util.ArrayList; 58 import java.util.Collections; 59 import java.util.Comparator; 60 import java.util.List; 61 import java.util.Map; 62 63 /** 64 * The teaser list item in the conversation list that shows nested folders. 65 */ 66 public class NestedFolderTeaserView extends LinearLayout implements ConversationSpecialItemView { 67 private static final String LOG_TAG = "NestedFolderTeaserView"; 68 69 private boolean mShouldDisplayInList = false; 70 71 private Account mAccount; 72 private Uri mFolderListUri; 73 private FolderSelector mListener; 74 75 private LoaderManager mLoaderManager = null; 76 private AnimatedAdapter mAdapter = null; 77 78 private final SparseArrayCompat<FolderHolder> mFolderHolders = 79 new SparseArrayCompat<FolderHolder>(); 80 81 private final int mFolderItemUpdateDelayMs; 82 83 private int mAnimatedHeight = -1; 84 85 private ViewGroup mNestedFolderContainer; 86 87 private View mShowMoreFoldersRow; 88 private TextView mShowMoreFoldersTextView; 89 private TextView mShowMoreFoldersCountTextView; 90 91 /** 92 * If <code>true</code> we show a limited set of folders, and a means to show all folders. If 93 * <code>false</code>, we show all folders. 94 */ 95 private boolean mCollapsed = true; 96 97 private View mTeaserRightEdge; 98 /** Whether we are on a tablet device or not */ 99 private final boolean mTabletDevice; 100 /** When in conversation mode, true if the list is hidden */ 101 private final boolean mListCollapsible; 102 103 /** If <code>true</code>, the list of folders has updated since the view was last shown. */ 104 private boolean mListUpdated; 105 106 // Each folder's loader will be this value plus the folder id 107 private static final int LOADER_FOLDER_LIST = 108 AbstractActivityController.LAST_FRAGMENT_LOADER_ID + 100000; 109 110 /** 111 * The maximum number of senders to show in the sender snippet. 112 */ 113 private static final String MAX_SENDERS = "20"; 114 115 /** 116 * The number of folders to show when the teaser is collapsed. 117 */ 118 private static int sCollapsedFolderThreshold = -1; 119 120 private static class FolderHolder { 121 private final View mItemView; 122 private final TextView mSendersTextView; 123 private final TextView mCountTextView; 124 private Folder mFolder; 125 private List<String> mUnreadSenders = ImmutableList.of(); 126 127 public FolderHolder(final View itemView, final TextView sendersTextView, 128 final TextView countTextView) { 129 mItemView = itemView; 130 mSendersTextView = sendersTextView; 131 mCountTextView = countTextView; 132 } 133 134 public void setFolder(final Folder folder) { 135 mFolder = folder; 136 } 137 138 public View getItemView() { 139 return mItemView; 140 } 141 142 public TextView getSendersTextView() { 143 return mSendersTextView; 144 } 145 146 public TextView getCountTextView() { 147 return mCountTextView; 148 } 149 150 public Folder getFolder() { 151 return mFolder; 152 } 153 154 /** 155 * @return a {@link List} of senders of unread messages 156 */ 157 public List<String> getUnreadSenders() { 158 return mUnreadSenders; 159 } 160 161 public void setUnreadSenders(final List<String> unreadSenders) { 162 mUnreadSenders = unreadSenders; 163 } 164 165 public static final Comparator<FolderHolder> NAME_COMPARATOR = 166 new Comparator<FolderHolder>() { 167 @Override 168 public int compare(final FolderHolder lhs, final FolderHolder rhs) { 169 return lhs.getFolder().name.compareTo(rhs.getFolder().name); 170 } 171 }; 172 } 173 174 public NestedFolderTeaserView(final Context context) { 175 this(context, null); 176 } 177 178 public NestedFolderTeaserView(final Context context, final AttributeSet attrs) { 179 this(context, attrs, -1); 180 } 181 182 public NestedFolderTeaserView( 183 final Context context, final AttributeSet attrs, final int defStyle) { 184 super(context, attrs, defStyle); 185 186 final Resources resources = context.getResources(); 187 188 if (sCollapsedFolderThreshold < 0) { 189 sCollapsedFolderThreshold = 190 resources.getInteger(R.integer.nested_folders_collapse_threshold); 191 } 192 193 mFolderItemUpdateDelayMs = 194 resources.getInteger(R.integer.folder_item_refresh_delay_ms); 195 196 mTabletDevice = com.android.mail.utils.Utils.useTabletUI(resources); 197 mListCollapsible = resources.getBoolean(R.bool.list_collapsible); 198 } 199 200 @Override 201 protected void onFinishInflate() { 202 mNestedFolderContainer = (ViewGroup) findViewById(R.id.nested_folder_container); 203 mTeaserRightEdge = findViewById(R.id.teaser_right_edge); 204 205 mShowMoreFoldersRow = findViewById(R.id.show_more_folders_row); 206 mShowMoreFoldersRow.setOnClickListener(mShowMoreOnClickListener); 207 208 mShowMoreFoldersTextView = (TextView) findViewById(R.id.show_more_folders_textView); 209 mShowMoreFoldersCountTextView = 210 (TextView) findViewById(R.id.show_more_folders_count_textView); 211 } 212 213 public void bind(final Account account, final FolderSelector listener) { 214 mAccount = account; 215 mListener = listener; 216 } 217 218 /** 219 * Creates a {@link FolderHolder}. 220 */ 221 private FolderHolder createFolderHolder(final CharSequence folderName) { 222 final View itemView = 223 LayoutInflater.from(getContext()).inflate(R.layout.folder_teaser_item, null); 224 225 final ImageView imageView = (ImageView) itemView.findViewById(R.id.folder_imageView); 226 imageView.setImageResource(R.drawable.ic_menu_move_to_holo_light); 227 // Remove background 228 imageView.setBackgroundColor(Color.TRANSPARENT); 229 230 ((TextView) itemView.findViewById(R.id.folder_textView)).setText(folderName); 231 final TextView sendersTextView = (TextView) itemView.findViewById(R.id.senders_textView); 232 final TextView countTextView = (TextView) itemView.findViewById(R.id.count_textView); 233 final FolderHolder holder = new FolderHolder(itemView, sendersTextView, countTextView); 234 235 attachOnClickListener(itemView, holder); 236 237 return holder; 238 } 239 240 private void attachOnClickListener(final View view, final FolderHolder holder) { 241 view.setOnClickListener(new OnClickListener() { 242 @Override 243 public void onClick(final View v) { 244 mListener.onFolderSelected(holder.getFolder()); 245 } 246 }); 247 } 248 249 @Override 250 public void onUpdate(final Folder folder, final ConversationCursor cursor) { 251 mShouldDisplayInList = false; // Assume disabled 252 253 if (folder == null) { 254 return; 255 } 256 257 final Uri folderListUri = folder.childFoldersListUri; 258 if (folderListUri == null) { 259 return; 260 } 261 262 // If we don't support nested folders, don't show this view 263 if (!mAccount.supportsCapability(AccountCapabilities.NESTED_FOLDERS)) { 264 return; 265 } 266 267 if (mFolderListUri == null || !mFolderListUri.equals(folder.childFoldersListUri)) { 268 // We have a new uri 269 mFolderListUri = folderListUri; 270 271 // Restart the loader 272 mLoaderManager.destroyLoader(LOADER_FOLDER_LIST); 273 mLoaderManager.initLoader(LOADER_FOLDER_LIST, null, mFolderListLoaderCallbacks); 274 } 275 276 mShouldDisplayInList = true; // Now we know we have something to display 277 } 278 279 @Override 280 public void onGetView() { 281 if (mListUpdated) { 282 // Clear out the folder views 283 mNestedFolderContainer.removeAllViews(); 284 285 // Sort the folders by name 286 // TODO(skennedy) recents? starred? 287 final ImmutableSortedSet.Builder<FolderHolder> folderHoldersBuilder = 288 new ImmutableSortedSet.Builder<FolderHolder>(FolderHolder.NAME_COMPARATOR); 289 290 for (int i = 0; i < mFolderHolders.size(); i++) { 291 folderHoldersBuilder.add(mFolderHolders.valueAt(i)); 292 } 293 294 final ImmutableSortedSet<FolderHolder> folderHolders = folderHoldersBuilder.build(); 295 296 // Add all folder views to the teaser 297 int added = 0; 298 // If we're only over the limit by one, don't truncate the list. 299 boolean truncate = folderHolders.size() > sCollapsedFolderThreshold + 1; 300 for (final FolderHolder folderHolder : folderHolders) { 301 mNestedFolderContainer.addView(folderHolder.getItemView()); 302 added++; 303 304 if (truncate && added >= sCollapsedFolderThreshold && mCollapsed) { 305 // We will display the rest when "Show more" is clicked 306 break; 307 } 308 } 309 310 updateShowMoreView(); 311 312 mListUpdated = false; 313 } 314 } 315 316 private final OnClickListener mShowMoreOnClickListener = new OnClickListener() { 317 @Override 318 public void onClick(final View v) { 319 mCollapsed = !mCollapsed; 320 mListUpdated = true; 321 mAdapter.notifyDataSetChanged(); 322 } 323 }; 324 325 private void updateShowMoreView() { 326 final int total = mFolderHolders.size(); 327 final int displayed = mNestedFolderContainer.getChildCount(); 328 final int notShown = total - displayed; 329 330 if (notShown > 0) { 331 // We are not displaying all the folders 332 mShowMoreFoldersRow.setVisibility(VISIBLE); 333 mShowMoreFoldersTextView.setText(String.format( 334 getContext().getString(R.string.show_n_more_folders), notShown)); 335 mShowMoreFoldersCountTextView.setVisibility(VISIBLE); 336 337 // Get a count of unread messages in other folders 338 int unreadCount = 0; 339 for (int i = 0; i < mFolderHolders.size(); i++) { 340 final FolderHolder holder = mFolderHolders.valueAt(i); 341 342 if (holder.getItemView().getParent() == null) { 343 // This view is not shown, so we want to use its unread count 344 // TODO(skennedy) We want a "nested" unread count, that includes the unread 345 // count of nested folders 346 unreadCount += holder.getFolder().unreadCount; 347 } 348 } 349 350 mShowMoreFoldersCountTextView.setText(Integer.toString(unreadCount)); 351 } else if (displayed > sCollapsedFolderThreshold + 1) { 352 // We are expanded 353 mShowMoreFoldersRow.setVisibility(VISIBLE); 354 mShowMoreFoldersTextView.setText(R.string.hide_folders); 355 mShowMoreFoldersCountTextView.setVisibility(GONE); 356 } else { 357 // We don't need to collapse the folders 358 mShowMoreFoldersRow.setVisibility(GONE); 359 } 360 } 361 362 private void updateViews(final FolderHolder folderHolder) { 363 final Folder folder = folderHolder.getFolder(); 364 365 final String unreadText = Utils.getUnreadCountString(getContext(), folder.unreadCount); 366 folderHolder.getCountTextView().setText(unreadText.isEmpty() ? "0" : unreadText); 367 368 final String sendersText = TextUtils.join( 369 getResources().getString(R.string.enumeration_comma), 370 folderHolder.getUnreadSenders()); 371 folderHolder.getSendersTextView().setText(sendersText); 372 } 373 374 @Override 375 public boolean getShouldDisplayInList() { 376 return mShouldDisplayInList; 377 } 378 379 @Override 380 public int getPosition() { 381 return 0; 382 } 383 384 @Override 385 public void setAdapter(final AnimatedAdapter adapter) { 386 mAdapter = adapter; 387 } 388 389 @Override 390 public void bindFragment(final LoaderManager loaderManager, final Bundle savedInstanceState) { 391 if (mLoaderManager != null) { 392 throw new IllegalStateException("This view has already been bound to a LoaderManager."); 393 } 394 395 mLoaderManager = loaderManager; 396 } 397 398 @Override 399 public void cleanup() { 400 // Do nothing 401 } 402 403 @Override 404 public void onConversationSelected() { 405 // Do nothing 406 } 407 408 @Override 409 public void onCabModeEntered() { 410 // Do nothing 411 } 412 413 @Override 414 public void onCabModeExited() { 415 // Do nothing 416 } 417 418 @Override 419 public void onConversationListVisibilityChanged(final boolean visible) { 420 // Do nothing 421 } 422 423 @Override 424 public void saveInstanceState(final Bundle outState) { 425 // Do nothing 426 } 427 428 @Override 429 public boolean acceptsUserTaps() { 430 // The teaser does not allow user tap in the list. 431 return false; 432 } 433 434 @Override 435 protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) { 436 if (com.android.mail.utils.Utils.getDisplayListRightEdgeEffect(mTabletDevice, 437 mListCollapsible, mAdapter.getViewMode())) { 438 mTeaserRightEdge.setVisibility(VISIBLE); 439 } else { 440 mTeaserRightEdge.setVisibility(GONE); 441 } 442 443 if (mAnimatedHeight == -1) { 444 super.onMeasure(widthMeasureSpec, heightMeasureSpec); 445 } else { 446 setMeasuredDimension(MeasureSpec.getSize(widthMeasureSpec), mAnimatedHeight); 447 } 448 } 449 450 private static int getLoaderId(final int folderId) { 451 return folderId + LOADER_FOLDER_LIST; 452 } 453 454 private static int getFolderId(final int loaderId) { 455 return loaderId - LOADER_FOLDER_LIST; 456 } 457 458 private final LoaderCallbacks<ObjectCursor<Folder>> mFolderListLoaderCallbacks = 459 new LoaderCallbacks<ObjectCursor<Folder>>() { 460 @Override 461 public void onLoaderReset(final Loader<ObjectCursor<Folder>> loader) { 462 // Do nothing 463 } 464 465 @Override 466 public void onLoadFinished(final Loader<ObjectCursor<Folder>> loader, 467 final ObjectCursor<Folder> data) { 468 if (data != null) { 469 // We need to keep track of all current folders in case one has been removed 470 final List<Integer> oldFolderIds = new ArrayList<Integer>(mFolderHolders.size()); 471 for (int i = 0; i < mFolderHolders.size(); i++) { 472 oldFolderIds.add(mFolderHolders.keyAt(i)); 473 } 474 475 if (data.moveToFirst()) { 476 do { 477 final Folder folder = data.getModel(); 478 final FolderHolder holder = mFolderHolders.get(folder.id); 479 480 if (holder != null) { 481 final Folder oldFolder = holder.getFolder(); 482 holder.setFolder(folder); 483 484 /* 485 * We only need to change anything if the old Folder was null, or the 486 * unread count has changed. 487 */ 488 if (oldFolder == null || oldFolder.unreadCount != folder.unreadCount) { 489 populateUnreadSenders(holder, folder.unreadSenders); 490 updateViews(holder); 491 } 492 } else { 493 // Create the holder, and init a loader 494 final FolderHolder newHolder = createFolderHolder(folder.name); 495 newHolder.setFolder(folder); 496 mFolderHolders.put(folder.id, newHolder); 497 498 // We can not support displaying sender info with nested folders 499 // because it doesn't scale. Disabling it for now, until we can 500 // optimize it. 501 // initFolderLoader(getLoaderId(folder.id)); 502 populateUnreadSenders(newHolder, folder.unreadSenders); 503 504 updateViews(newHolder); 505 506 mListUpdated = true; 507 } 508 509 // Note: #remove(int) removes from that POSITION 510 // #remove(Integer) removes that OBJECT 511 oldFolderIds.remove(Integer.valueOf(folder.id)); 512 } while (data.moveToNext()); 513 } 514 515 for (final int folderId : oldFolderIds) { 516 // We have a folder that no longer exists 517 mFolderHolders.remove(folderId); 518 mLoaderManager.destroyLoader(getLoaderId(folderId)); 519 mListUpdated = true; 520 } 521 522 // If the list has not changed, we've already updated the counts, etc. 523 // If the list has changed, we need to rebuild it 524 if (mListUpdated) { 525 mAdapter.notifyDataSetChanged(); 526 } 527 } else { 528 LogUtils.w(LOG_TAG, "Problem with folder list cursor returned from loader"); 529 } 530 } 531 532 private void initFolderLoader(final int loaderId) { 533 LogUtils.d(LOG_TAG, "Initializing folder loader %d", loaderId); 534 mLoaderManager.initLoader(loaderId, null, mFolderLoaderCallbacks); 535 } 536 537 @Override 538 public Loader<ObjectCursor<Folder>> onCreateLoader(final int id, final Bundle args) { 539 final ObjectCursorLoader<Folder> loader = new ObjectCursorLoader<Folder>(getContext(), 540 mFolderListUri, UIProvider.FOLDERS_PROJECTION_WITH_UNREAD_SENDERS, 541 Folder.FACTORY); 542 loader.setUpdateThrottle(mFolderItemUpdateDelayMs); 543 return loader; 544 } 545 }; 546 547 /** 548 * This code is intended to roughly duplicate the FolderLoaderCallback's onLoadFinished 549 */ 550 private void populateUnreadSenders(final FolderHolder folderHolder, 551 final String unreadSenders) { 552 if (TextUtils.isEmpty(unreadSenders)) { 553 folderHolder.setUnreadSenders(Collections.<String>emptyList()); 554 return; 555 } 556 // Use a LinkedHashMap here to maintain ordering 557 final Map<String, String> emailtoNameMap = Maps.newLinkedHashMap(); 558 559 final Address[] senderAddresses = Address.parse(unreadSenders); 560 561 final BidiFormatter bidiFormatter = mAdapter.getBidiFormatter(); 562 for (final Address senderAddress : senderAddresses) { 563 String sender = senderAddress.getPersonal(); 564 sender = (sender != null) ? bidiFormatter.unicodeWrap(sender) : null; 565 final String senderEmail = senderAddress.getAddress(); 566 567 if (!TextUtils.isEmpty(sender)) { 568 final String existingSender = emailtoNameMap.get(senderEmail); 569 if (!TextUtils.isEmpty(existingSender)) { 570 // Prefer longer names 571 if (existingSender.length() >= sender.length()) { 572 // old name is longer 573 sender = existingSender; 574 } 575 } 576 emailtoNameMap.put(senderEmail, sender); 577 } 578 if (emailtoNameMap.size() >= 20) { 579 break; 580 } 581 } 582 583 final List<String> senders = Lists.newArrayList(emailtoNameMap.values()); 584 folderHolder.setUnreadSenders(senders); 585 } 586 587 private final LoaderCallbacks<ObjectCursor<Conversation>> mFolderLoaderCallbacks = 588 new LoaderCallbacks<ObjectCursor<Conversation>>() { 589 @Override 590 public void onLoaderReset(final Loader<ObjectCursor<Conversation>> loader) { 591 // Do nothing 592 } 593 594 @Override 595 public void onLoadFinished(final Loader<ObjectCursor<Conversation>> loader, 596 final ObjectCursor<Conversation> data) { 597 // Sometimes names are condensed to just the first name. 598 // This data structure keeps a map of emails to names 599 final Map<String, String> emailToNameMap = Maps.newHashMap(); 600 final List<String> senders = Lists.newArrayList(); 601 602 final int folderId = getFolderId(loader.getId()); 603 604 final FolderHolder folderHolder = mFolderHolders.get(folderId); 605 final int maxSenders = folderHolder.mFolder.unreadCount; 606 607 if (maxSenders > 0 && data != null && data.moveToFirst()) { 608 LogUtils.d(LOG_TAG, "Folder id %d loader finished", folderId); 609 610 // Look through all conversations until we find 'maxSenders' unread 611 int sendersFound = 0; 612 613 do { 614 final Conversation conversation = data.getModel(); 615 616 if (!conversation.read) { 617 String sender = null; 618 String senderEmail = null; 619 int priority = Integer.MIN_VALUE; 620 621 // Find the highest priority participant 622 for (final ParticipantInfo p : 623 conversation.conversationInfo.participantInfos) { 624 if (sender == null || priority < p.priority) { 625 sender = p.name; 626 senderEmail = p.email; 627 priority = p.priority; 628 } 629 } 630 631 if (sender != null) { 632 sendersFound++; 633 final String existingSender = emailToNameMap.get(senderEmail); 634 if (existingSender != null) { 635 // Prefer longer names 636 if (existingSender.length() >= sender.length()) { 637 // old name is longer 638 sender = existingSender; 639 } else { 640 // new name is longer 641 int index = senders.indexOf(existingSender); 642 senders.set(index, sender); 643 } 644 } else { 645 senders.add(sender); 646 } 647 emailToNameMap.put(senderEmail, sender); 648 } 649 } 650 } while (data.moveToNext() && sendersFound < maxSenders); 651 } else { 652 LogUtils.w(LOG_TAG, "Problem with folder cursor returned from loader"); 653 } 654 655 folderHolder.setUnreadSenders(senders); 656 657 /* 658 * Just update the views in place. We don't need to call notifyDataSetChanged() 659 * because we aren't changing the teaser's visibility or position. 660 */ 661 updateViews(folderHolder); 662 } 663 664 @Override 665 public Loader<ObjectCursor<Conversation>> onCreateLoader(final int id, final Bundle args) { 666 final int folderId = getFolderId(id); 667 final Uri uri = mFolderHolders.get(folderId).mFolder.conversationListUri 668 .buildUpon() 669 .appendQueryParameter(ConversationListQueryParameters.USE_NETWORK, 670 Boolean.FALSE.toString()) 671 .appendQueryParameter(ConversationListQueryParameters.LIMIT, MAX_SENDERS) 672 .build(); 673 return new ObjectCursorLoader<Conversation>(getContext(), uri, 674 UIProvider.CONVERSATION_PROJECTION, Conversation.FACTORY); 675 } 676 }; 677 678 @Override 679 public boolean commitLeaveBehindItem() { 680 // This view has no leave-behind 681 return false; 682 } 683 } 684