Home | History | Annotate | Download | only in browse
      1 /*
      2  * Copyright (C) 2012 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 
     18 package com.android.mail.browse;
     19 
     20 import android.content.BroadcastReceiver;
     21 import android.content.Context;
     22 import android.content.Intent;
     23 import android.content.IntentFilter;
     24 import android.content.res.Resources;
     25 import android.graphics.Typeface;
     26 import android.support.v4.text.BidiFormatter;
     27 import android.text.Spannable;
     28 import android.text.SpannableString;
     29 import android.text.SpannableStringBuilder;
     30 import android.text.TextUtils;
     31 import android.text.style.CharacterStyle;
     32 import android.text.style.TextAppearanceSpan;
     33 import android.text.util.Rfc822Token;
     34 import android.text.util.Rfc822Tokenizer;
     35 
     36 import com.android.mail.R;
     37 import com.android.mail.providers.Address;
     38 import com.android.mail.providers.Conversation;
     39 import com.android.mail.providers.ConversationInfo;
     40 import com.android.mail.providers.MessageInfo;
     41 import com.android.mail.providers.UIProvider;
     42 import com.android.mail.ui.DividedImageCanvas;
     43 import com.android.mail.utils.ObjectCache;
     44 import com.google.common.base.Objects;
     45 import com.google.common.collect.Maps;
     46 
     47 import java.util.ArrayList;
     48 import java.util.Locale;
     49 import java.util.Map;
     50 
     51 import java.util.regex.Pattern;
     52 
     53 public class SendersView {
     54     public static final int DEFAULT_FORMATTING = 0;
     55     public static final int MERGED_FORMATTING = 1;
     56     private static final Integer DOES_NOT_EXIST = -5;
     57     // FIXME(ath): make all of these statics instance variables, and have callers hold onto this
     58     // instance as long as appropriate (e.g. activity lifetime).
     59     // no need to listen for configuration changes.
     60     private static String sSendersSplitToken;
     61     public static String SENDERS_VERSION_SEPARATOR = "^**^";
     62     public static Pattern SENDERS_VERSION_SEPARATOR_PATTERN = Pattern.compile("\\^\\*\\*\\^");
     63     private static CharSequence sDraftSingularString;
     64     private static CharSequence sDraftPluralString;
     65     private static CharSequence sSendingString;
     66     private static String sDraftCountFormatString;
     67     private static CharacterStyle sDraftsStyleSpan;
     68     private static CharacterStyle sSendingStyleSpan;
     69     private static TextAppearanceSpan sUnreadStyleSpan;
     70     private static CharacterStyle sReadStyleSpan;
     71     private static String sMeString;
     72     private static Locale sMeStringLocale;
     73     private static String sMessageCountSpacerString;
     74     public static CharSequence sElidedString;
     75     private static BroadcastReceiver sConfigurationChangedReceiver;
     76     private static TextAppearanceSpan sMessageInfoReadStyleSpan;
     77     private static TextAppearanceSpan sMessageInfoUnreadStyleSpan;
     78     private static BidiFormatter sBidiFormatter;
     79 
     80     // We only want to have at most 2 Priority to length maps.  This will handle the case where
     81     // there is a widget installed on the launcher while the user is scrolling in the app
     82     private static final int MAX_PRIORITY_LENGTH_MAP_LIST = 2;
     83 
     84     // Cache of priority to length maps.  We can't just use a single instance as it may be
     85     // modified from different threads
     86     private static final ObjectCache<Map<Integer, Integer>> PRIORITY_LENGTH_MAP_CACHE =
     87             new ObjectCache<Map<Integer, Integer>>(
     88                     new ObjectCache.Callback<Map<Integer, Integer>>() {
     89                         @Override
     90                         public Map<Integer, Integer> newInstance() {
     91                             return Maps.newHashMap();
     92                         }
     93                         @Override
     94                         public void onObjectReleased(Map<Integer, Integer> object) {
     95                             object.clear();
     96                         }
     97                     }, MAX_PRIORITY_LENGTH_MAP_LIST);
     98 
     99     public static Typeface getTypeface(boolean isUnread) {
    100         return isUnread ? Typeface.DEFAULT_BOLD : Typeface.DEFAULT;
    101     }
    102 
    103     private static synchronized void getSenderResources(
    104             Context context, final boolean resourceCachingRequired) {
    105         if (sConfigurationChangedReceiver == null && resourceCachingRequired) {
    106             sConfigurationChangedReceiver = new BroadcastReceiver() {
    107                 @Override
    108                 public void onReceive(Context context, Intent intent) {
    109                     sDraftSingularString = null;
    110                     getSenderResources(context, true);
    111                 }
    112             };
    113             context.registerReceiver(sConfigurationChangedReceiver, new IntentFilter(
    114                     Intent.ACTION_CONFIGURATION_CHANGED));
    115         }
    116         if (sDraftSingularString == null) {
    117             Resources res = context.getResources();
    118             sSendersSplitToken = res.getString(R.string.senders_split_token);
    119             sElidedString = res.getString(R.string.senders_elided);
    120             sDraftSingularString = res.getQuantityText(R.plurals.draft, 1);
    121             sDraftPluralString = res.getQuantityText(R.plurals.draft, 2);
    122             sDraftCountFormatString = res.getString(R.string.draft_count_format);
    123             sMessageInfoUnreadStyleSpan = new TextAppearanceSpan(context,
    124                     R.style.MessageInfoUnreadTextAppearance);
    125             sMessageInfoReadStyleSpan = new TextAppearanceSpan(context,
    126                     R.style.MessageInfoReadTextAppearance);
    127             sDraftsStyleSpan = new TextAppearanceSpan(context, R.style.DraftTextAppearance);
    128             sUnreadStyleSpan = new TextAppearanceSpan(context, R.style.SendersUnreadTextAppearance);
    129             sSendingStyleSpan = new TextAppearanceSpan(context, R.style.SendingTextAppearance);
    130             sReadStyleSpan = new TextAppearanceSpan(context, R.style.SendersReadTextAppearance);
    131             sMessageCountSpacerString = res.getString(R.string.message_count_spacer);
    132             sSendingString = res.getString(R.string.sending);
    133             sBidiFormatter = BidiFormatter.getInstance();
    134         }
    135     }
    136 
    137     public static SpannableStringBuilder createMessageInfo(Context context, Conversation conv,
    138             final boolean resourceCachingRequired) {
    139         SpannableStringBuilder messageInfo = new SpannableStringBuilder();
    140 
    141         try {
    142             ConversationInfo conversationInfo = conv.conversationInfo;
    143             int sendingStatus = conv.sendingState;
    144             boolean hasSenders = false;
    145             // This covers the case where the sender is "me" and this is a draft
    146             // message, which means this will only run once most of the time.
    147             for (MessageInfo m : conversationInfo.messageInfos) {
    148                 if (!TextUtils.isEmpty(m.sender)) {
    149                     hasSenders = true;
    150                     break;
    151                 }
    152             }
    153             getSenderResources(context, resourceCachingRequired);
    154             if (conversationInfo != null) {
    155                 int count = conversationInfo.messageCount;
    156                 int draftCount = conversationInfo.draftCount;
    157                 boolean showSending = sendingStatus == UIProvider.ConversationSendingState.SENDING;
    158                 if (count > 1) {
    159                     messageInfo.append(count + "");
    160                 }
    161                 messageInfo.setSpan(CharacterStyle.wrap(
    162                         conv.read ? sMessageInfoReadStyleSpan : sMessageInfoUnreadStyleSpan),
    163                         0, messageInfo.length(), 0);
    164                 if (draftCount > 0) {
    165                     // If we are showing a message count or any draft text and there
    166                     // is at least 1 sender, prepend the sending state text with a
    167                     // comma.
    168                     if (hasSenders || count > 1) {
    169                         messageInfo.append(sSendersSplitToken);
    170                     }
    171                     SpannableStringBuilder draftString = new SpannableStringBuilder();
    172                     if (draftCount == 1) {
    173                         draftString.append(sDraftSingularString);
    174                     } else {
    175                         draftString.append(sDraftPluralString
    176                                 + String.format(sDraftCountFormatString, draftCount));
    177                     }
    178                     draftString.setSpan(CharacterStyle.wrap(sDraftsStyleSpan), 0,
    179                             draftString.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
    180                     messageInfo.append(draftString);
    181                 }
    182                 if (showSending) {
    183                     // If we are showing a message count or any draft text, prepend
    184                     // the sending state text with a comma.
    185                     if (count > 1 || draftCount > 0) {
    186                         messageInfo.append(sSendersSplitToken);
    187                     }
    188                     SpannableStringBuilder sending = new SpannableStringBuilder();
    189                     sending.append(sSendingString);
    190                     sending.setSpan(sSendingStyleSpan, 0, sending.length(), 0);
    191                     messageInfo.append(sending);
    192                 }
    193                 // Prepend a space if we are showing other message info text.
    194                 if (count > 1 || (draftCount > 0 && hasSenders) || showSending) {
    195                     messageInfo.insert(0, sMessageCountSpacerString);
    196                 }
    197             }
    198         } finally {
    199             if (!resourceCachingRequired) {
    200                 clearResourceCache();
    201             }
    202         }
    203 
    204         return messageInfo;
    205     }
    206 
    207     public static void format(Context context, ConversationInfo conversationInfo,
    208             String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders,
    209             ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails,
    210             String account, final boolean resourceCachingRequired) {
    211         try {
    212             getSenderResources(context, resourceCachingRequired);
    213             format(context, conversationInfo, messageInfo, maxChars, styledSenders,
    214                     displayableSenderNames, displayableSenderEmails, account,
    215                     sUnreadStyleSpan, sReadStyleSpan, resourceCachingRequired);
    216         } finally {
    217             if (!resourceCachingRequired) {
    218                 clearResourceCache();
    219             }
    220         }
    221     }
    222 
    223     public static void format(Context context, ConversationInfo conversationInfo,
    224             String messageInfo, int maxChars, ArrayList<SpannableString> styledSenders,
    225             ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails,
    226             String account, final TextAppearanceSpan notificationUnreadStyleSpan,
    227             final CharacterStyle notificationReadStyleSpan, final boolean resourceCachingRequired) {
    228         try {
    229             getSenderResources(context, resourceCachingRequired);
    230             handlePriority(context, maxChars, messageInfo, conversationInfo, styledSenders,
    231                     displayableSenderNames, displayableSenderEmails, account,
    232                     notificationUnreadStyleSpan, notificationReadStyleSpan);
    233         } finally {
    234             if (!resourceCachingRequired) {
    235                 clearResourceCache();
    236             }
    237         }
    238     }
    239 
    240     public static void handlePriority(Context context, int maxChars, String messageInfoString,
    241             ConversationInfo conversationInfo, ArrayList<SpannableString> styledSenders,
    242             ArrayList<String> displayableSenderNames, ArrayList<String> displayableSenderEmails,
    243             String account, final TextAppearanceSpan unreadStyleSpan,
    244             final CharacterStyle readStyleSpan) {
    245         boolean shouldAddPhotos = displayableSenderEmails != null;
    246         int maxPriorityToInclude = -1; // inclusive
    247         int numCharsUsed = messageInfoString.length(); // draft, number drafts,
    248                                                        // count
    249         int numSendersUsed = 0;
    250         int numCharsToRemovePerWord = 0;
    251         int maxFoundPriority = 0;
    252         if (numCharsUsed > maxChars) {
    253             numCharsToRemovePerWord = numCharsUsed - maxChars;
    254         }
    255 
    256         final Map<Integer, Integer> priorityToLength = PRIORITY_LENGTH_MAP_CACHE.get();
    257         try {
    258             priorityToLength.clear();
    259             int senderLength;
    260             for (MessageInfo info : conversationInfo.messageInfos) {
    261                 senderLength = !TextUtils.isEmpty(info.sender) ? info.sender.length() : 0;
    262                 priorityToLength.put(info.priority, senderLength);
    263                 maxFoundPriority = Math.max(maxFoundPriority, info.priority);
    264             }
    265             while (maxPriorityToInclude < maxFoundPriority) {
    266                 if (priorityToLength.containsKey(maxPriorityToInclude + 1)) {
    267                     int length = numCharsUsed + priorityToLength.get(maxPriorityToInclude + 1);
    268                     if (numCharsUsed > 0)
    269                         length += 2;
    270                     // We must show at least two senders if they exist. If we don't
    271                     // have space for both
    272                     // then we will truncate names.
    273                     if (length > maxChars && numSendersUsed >= 2) {
    274                         break;
    275                     }
    276                     numCharsUsed = length;
    277                     numSendersUsed++;
    278                 }
    279                 maxPriorityToInclude++;
    280             }
    281         } finally {
    282             PRIORITY_LENGTH_MAP_CACHE.release(priorityToLength);
    283         }
    284         // We want to include this entry if
    285         // 1) The onlyShowUnread flags is not set
    286         // 2) The above flag is set, and the message is unread
    287         MessageInfo currentMessage;
    288         SpannableString spannableDisplay;
    289         String nameString;
    290         CharacterStyle style;
    291         boolean appendedElided = false;
    292         Map<String, Integer> displayHash = Maps.newHashMap();
    293         String firstDisplayableSenderEmail = null;
    294         String firstDisplayableSender = null;
    295         for (int i = 0; i < conversationInfo.messageInfos.size(); i++) {
    296             currentMessage = conversationInfo.messageInfos.get(i);
    297             nameString = !TextUtils.isEmpty(currentMessage.sender) ? currentMessage.sender : "";
    298             if (nameString.length() == 0) {
    299                 nameString = getMe(context);
    300             }
    301             if (numCharsToRemovePerWord != 0) {
    302                 nameString = nameString.substring(0,
    303                         Math.max(nameString.length() - numCharsToRemovePerWord, 0));
    304             }
    305             final int priority = currentMessage.priority;
    306             style = !currentMessage.read ? getWrappedStyleSpan(unreadStyleSpan)
    307                     : getWrappedStyleSpan(readStyleSpan);
    308             if (priority <= maxPriorityToInclude) {
    309                 spannableDisplay = new SpannableString(sBidiFormatter.unicodeWrap(nameString));
    310                 // Don't duplicate senders; leave the first instance, unless the
    311                 // current instance is also unread.
    312                 int oldPos = displayHash.containsKey(currentMessage.sender) ? displayHash
    313                         .get(currentMessage.sender) : DOES_NOT_EXIST;
    314                 // If this sender doesn't exist OR the current message is
    315                 // unread, add the sender.
    316                 if (oldPos == DOES_NOT_EXIST || !currentMessage.read) {
    317                     // If the sender entry already existed, and is right next to the
    318                     // current sender, remove the old entry.
    319                     if (oldPos != DOES_NOT_EXIST && i > 0 && oldPos == i - 1
    320                             && oldPos < styledSenders.size()) {
    321                         // Remove the old one!
    322                         styledSenders.set(oldPos, null);
    323                         if (shouldAddPhotos && !TextUtils.isEmpty(currentMessage.senderEmail)) {
    324                             displayableSenderEmails.remove(currentMessage.senderEmail);
    325                             displayableSenderNames.remove(currentMessage.sender);
    326                         }
    327                     }
    328                     displayHash.put(currentMessage.sender, i);
    329                     spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0);
    330                     styledSenders.add(spannableDisplay);
    331                 }
    332             } else {
    333                 if (!appendedElided) {
    334                     spannableDisplay = new SpannableString(sElidedString);
    335                     spannableDisplay.setSpan(style, 0, spannableDisplay.length(), 0);
    336                     appendedElided = true;
    337                     styledSenders.add(spannableDisplay);
    338                 }
    339             }
    340             if (shouldAddPhotos) {
    341                 String senderEmail = TextUtils.isEmpty(currentMessage.sender) ?
    342                         account :
    343                             TextUtils.isEmpty(currentMessage.senderEmail) ?
    344                                     currentMessage.sender : currentMessage.senderEmail;
    345                 if (i == 0) {
    346                     // Always add the first sender!
    347                     firstDisplayableSenderEmail = senderEmail;
    348                     firstDisplayableSender = currentMessage.sender;
    349                 } else {
    350                     if (!Objects.equal(firstDisplayableSenderEmail, senderEmail)) {
    351                         int indexOf = displayableSenderEmails.indexOf(senderEmail);
    352                         if (indexOf > -1) {
    353                             displayableSenderEmails.remove(indexOf);
    354                             displayableSenderNames.remove(indexOf);
    355                         }
    356                         displayableSenderEmails.add(senderEmail);
    357                         displayableSenderNames.add(currentMessage.sender);
    358                         if (displayableSenderEmails.size() > DividedImageCanvas.MAX_DIVISIONS) {
    359                             displayableSenderEmails.remove(0);
    360                             displayableSenderNames.remove(0);
    361                         }
    362                     }
    363                 }
    364             }
    365         }
    366         if (shouldAddPhotos && !TextUtils.isEmpty(firstDisplayableSenderEmail)) {
    367             if (displayableSenderEmails.size() < DividedImageCanvas.MAX_DIVISIONS) {
    368                 displayableSenderEmails.add(0, firstDisplayableSenderEmail);
    369                 displayableSenderNames.add(0, firstDisplayableSender);
    370             } else {
    371                 displayableSenderEmails.set(0, firstDisplayableSenderEmail);
    372                 displayableSenderNames.set(0, firstDisplayableSender);
    373             }
    374         }
    375     }
    376 
    377     private static CharacterStyle getWrappedStyleSpan(final CharacterStyle characterStyle) {
    378         return CharacterStyle.wrap(characterStyle);
    379     }
    380 
    381     static String getMe(Context context) {
    382         final Resources resources = context.getResources();
    383         final Locale locale = resources.getConfiguration().locale;
    384 
    385         if (sMeString == null || !locale.equals(sMeStringLocale)) {
    386             sMeString = resources.getString(R.string.me_subject_pronun);
    387             sMeStringLocale = locale;
    388         }
    389         return sMeString;
    390     }
    391 
    392     private static void formatDefault(ConversationItemViewModel header, String sendersString,
    393             Context context, final CharacterStyle readStyleSpan,
    394             final boolean resourceCachingRequired) {
    395         try {
    396             getSenderResources(context, resourceCachingRequired);
    397             // Clear any existing sender fragments; we must re-make all of them.
    398             header.senderFragments.clear();
    399             // TODO: unify this with ConversationItemView.calculateTextsAndBitmaps's tokenization
    400             final Rfc822Token[] senders = Rfc822Tokenizer.tokenize(sendersString);
    401             final String[] namesOnly = new String[senders.length];
    402             String display;
    403             for (int i = 0; i < senders.length; i++) {
    404                 display = Address.decodeAddressName(senders[i].getName());
    405                 if (TextUtils.isEmpty(display)) {
    406                     display = senders[i].getAddress();
    407                 }
    408                 namesOnly[i] = display;
    409             }
    410             generateSenderFragments(header, namesOnly, readStyleSpan);
    411         } finally {
    412             if (!resourceCachingRequired) {
    413                 clearResourceCache();
    414             }
    415         }
    416     }
    417 
    418     private static void generateSenderFragments(ConversationItemViewModel header, String[] names,
    419             final CharacterStyle readStyleSpan) {
    420         header.sendersText = TextUtils.join(Address.ADDRESS_DELIMETER + " ", names);
    421         header.addSenderFragment(0, header.sendersText.length(), getWrappedStyleSpan(readStyleSpan),
    422                 true);
    423     }
    424 
    425     public static void formatSenders(ConversationItemViewModel header, Context context,
    426             final boolean resourceCachingRequired) {
    427         try {
    428             getSenderResources(context, resourceCachingRequired);
    429             formatSenders(header, context, sReadStyleSpan, resourceCachingRequired);
    430         } finally {
    431             if (!resourceCachingRequired) {
    432                 clearResourceCache();
    433             }
    434         }
    435     }
    436 
    437     public static void formatSenders(ConversationItemViewModel header, Context context,
    438             final CharacterStyle readStyleSpan, final boolean resourceCachingRequired) {
    439         try {
    440             formatDefault(header, header.conversation.senders, context, readStyleSpan,
    441                     resourceCachingRequired);
    442         } finally {
    443             if (!resourceCachingRequired) {
    444                 clearResourceCache();
    445             }
    446         }
    447     }
    448 
    449     private static void clearResourceCache() {
    450         sDraftSingularString = null;
    451     }
    452 }
    453