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