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