1 package com.android.ex.chips;
2 
3 import android.content.Context;
4 import android.content.res.Resources;
5 import android.graphics.Bitmap;
6 import android.graphics.BitmapFactory;
7 import android.graphics.Color;
8 import android.graphics.PorterDuff;
9 import android.graphics.drawable.Drawable;
10 import android.graphics.drawable.StateListDrawable;
11 import android.net.Uri;
12 import androidx.annotation.DrawableRes;
13 import androidx.annotation.IdRes;
14 import androidx.annotation.LayoutRes;
15 import androidx.annotation.Nullable;
16 import androidx.core.view.MarginLayoutParamsCompat;
17 import android.text.SpannableStringBuilder;
18 import android.text.Spanned;
19 import android.text.TextUtils;
20 import android.text.style.ForegroundColorSpan;
21 import android.text.util.Rfc822Tokenizer;
22 import android.view.LayoutInflater;
23 import android.view.View;
24 import android.view.View.OnClickListener;
25 import android.view.ViewGroup;
26 import android.view.ViewGroup.MarginLayoutParams;
27 import android.widget.ImageView;
28 import android.widget.TextView;
29 
30 import com.android.ex.chips.Queries.Query;
31 
32 /**
33  * A class that inflates and binds the views in the dropdown list from
34  * RecipientEditTextView.
35  */
36 public class DropdownChipLayouter {
37     /**
38      * The type of adapter that is requesting a chip layout.
39      */
40     public enum AdapterType {
41         BASE_RECIPIENT,
42         RECIPIENT_ALTERNATES,
43         SINGLE_RECIPIENT
44     }
45 
46     public interface ChipDeleteListener {
onChipDelete()47         void onChipDelete();
48     }
49 
50     /**
51      * Listener that handles the dismisses of the entries of the
52      * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type.
53      */
54     public interface PermissionRequestDismissedListener {
55 
56         /**
57          * Callback that occurs when user dismisses the item that asks user to grant permissions to
58          * the app.
59          */
onPermissionRequestDismissed()60         void onPermissionRequestDismissed();
61     }
62 
63     private final LayoutInflater mInflater;
64     private final Context mContext;
65     private ChipDeleteListener mDeleteListener;
66     private PermissionRequestDismissedListener mPermissionRequestDismissedListener;
67     private Query mQuery;
68     private int mAutocompleteDividerMarginStart;
69 
DropdownChipLayouter(LayoutInflater inflater, Context context)70     public DropdownChipLayouter(LayoutInflater inflater, Context context) {
71         mInflater = inflater;
72         mContext = context;
73         mAutocompleteDividerMarginStart =
74                 context.getResources().getDimensionPixelOffset(R.dimen.chip_wrapper_start_padding);
75     }
76 
setQuery(Query query)77     public void setQuery(Query query) {
78         mQuery = query;
79     }
80 
setDeleteListener(ChipDeleteListener listener)81     public void setDeleteListener(ChipDeleteListener listener) {
82         mDeleteListener = listener;
83     }
84 
setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener)85     public void setPermissionRequestDismissedListener(PermissionRequestDismissedListener listener) {
86         mPermissionRequestDismissedListener = listener;
87     }
88 
setAutocompleteDividerMarginStart(int autocompleteDividerMarginStart)89     public void setAutocompleteDividerMarginStart(int autocompleteDividerMarginStart) {
90         mAutocompleteDividerMarginStart = autocompleteDividerMarginStart;
91     }
92 
93     /**
94      * Layouts and binds recipient information to the view. If convertView is null, inflates a new
95      * view with getItemLaytout().
96      *
97      * @param convertView The view to bind information to.
98      * @param parent The parent to bind the view to if we inflate a new view.
99      * @param entry The recipient entry to get information from.
100      * @param position The position in the list.
101      * @param type The adapter type that is requesting the bind.
102      * @param constraint The constraint typed in the auto complete view.
103      *
104      * @return A view ready to be shown in the drop down list.
105      */
bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position, AdapterType type, String constraint)106     public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position,
107         AdapterType type, String constraint) {
108         return bindView(convertView, parent, entry, position, type, constraint, null);
109     }
110 
111     /**
112      * See {@link #bindView(View, ViewGroup, RecipientEntry, int, AdapterType, String)}
113      * @param deleteDrawable a {@link android.graphics.drawable.StateListDrawable} representing
114      *     the delete icon. android.R.attr.state_activated should map to the delete icon, and the
115      *     default state can map to a drawable of your choice (or null for no drawable).
116      */
bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position, AdapterType type, String constraint, StateListDrawable deleteDrawable)117     public View bindView(View convertView, ViewGroup parent, RecipientEntry entry, int position,
118             AdapterType type, String constraint, StateListDrawable deleteDrawable) {
119         // Default to show all the information
120         CharSequence[] styledResults = getStyledResults(constraint, entry);
121         CharSequence displayName = styledResults[0];
122         CharSequence destination = styledResults[1];
123         boolean showImage = true;
124         CharSequence destinationType = getDestinationType(entry);
125 
126         final View itemView = reuseOrInflateView(convertView, parent, type);
127 
128         final ViewHolder viewHolder = new ViewHolder(itemView);
129 
130         // Hide some information depending on the adapter type.
131         switch (type) {
132             case BASE_RECIPIENT:
133                 if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, destination)) {
134                     displayName = destination;
135 
136                     // We only show the destination for secondary entries, so clear it only for the
137                     // first level.
138                     if (entry.isFirstLevel()) {
139                         destination = null;
140                     }
141                 }
142 
143                 if (!entry.isFirstLevel()) {
144                     displayName = null;
145                     showImage = false;
146                 }
147 
148                 // For BASE_RECIPIENT set all top dividers except for the first one to be GONE.
149                 if (viewHolder.topDivider != null) {
150                     viewHolder.topDivider.setVisibility(position == 0 ? View.VISIBLE : View.GONE);
151                     MarginLayoutParamsCompat.setMarginStart(
152                             (MarginLayoutParams) viewHolder.topDivider.getLayoutParams(),
153                             mAutocompleteDividerMarginStart);
154                 }
155                 if (viewHolder.bottomDivider != null) {
156                     MarginLayoutParamsCompat.setMarginStart(
157                             (MarginLayoutParams) viewHolder.bottomDivider.getLayoutParams(),
158                             mAutocompleteDividerMarginStart);
159                 }
160                 break;
161             case RECIPIENT_ALTERNATES:
162                 if (position != 0) {
163                     displayName = null;
164                     showImage = false;
165                 }
166                 break;
167             case SINGLE_RECIPIENT:
168                 if (!PhoneUtil.isPhoneNumber(entry.getDestination())) {
169                     destination = Rfc822Tokenizer.tokenize(entry.getDestination())[0].getAddress();
170                 }
171                 destinationType = null;
172         }
173 
174         // Bind the information to the view
175         bindTextToView(displayName, viewHolder.displayNameView);
176         bindTextToView(destination, viewHolder.destinationView);
177         bindTextToView(destinationType, viewHolder.destinationTypeView);
178         bindIconToView(showImage, entry, viewHolder.imageView, type);
179         bindDrawableToDeleteView(deleteDrawable, entry.getDisplayName(), viewHolder.deleteView);
180         bindIndicatorToView(
181                 entry.getIndicatorIconId(), entry.getIndicatorText(), viewHolder.indicatorView);
182         bindPermissionRequestDismissView(viewHolder.permissionRequestDismissView);
183 
184         // Hide some view groups depending on the entry type
185         final int entryType = entry.getEntryType();
186         if (entryType == RecipientEntry.ENTRY_TYPE_PERSON) {
187             setViewVisibility(viewHolder.personViewGroup, View.VISIBLE);
188             setViewVisibility(viewHolder.permissionViewGroup, View.GONE);
189             setViewVisibility(viewHolder.permissionBottomDivider, View.GONE);
190         } else if (entryType == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
191             setViewVisibility(viewHolder.personViewGroup, View.GONE);
192             setViewVisibility(viewHolder.permissionViewGroup, View.VISIBLE);
193             setViewVisibility(viewHolder.permissionBottomDivider, View.VISIBLE);
194         }
195 
196         return itemView;
197     }
198 
199     /**
200      * Returns a new view with {@link #getItemLayoutResId(AdapterType)}.
201      */
newView(AdapterType type)202     public View newView(AdapterType type) {
203         return mInflater.inflate(getItemLayoutResId(type), null);
204     }
205 
206     /**
207      * Returns the same view, or inflates a new one if the given view was null.
208      */
reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type)209     protected View reuseOrInflateView(View convertView, ViewGroup parent, AdapterType type) {
210         int itemLayout = getItemLayoutResId(type);
211         switch (type) {
212             case BASE_RECIPIENT:
213             case RECIPIENT_ALTERNATES:
214                 break;
215             case SINGLE_RECIPIENT:
216                 itemLayout = getAlternateItemLayoutResId(type);
217                 break;
218         }
219         return convertView != null ? convertView : mInflater.inflate(itemLayout, parent, false);
220     }
221 
222     /**
223      * Binds the text to the given text view. If the text was null, hides the text view.
224      */
bindTextToView(CharSequence text, TextView view)225     protected void bindTextToView(CharSequence text, TextView view) {
226         if (view == null) {
227             return;
228         }
229 
230         if (text != null) {
231             view.setText(text);
232             view.setVisibility(View.VISIBLE);
233         } else {
234             view.setVisibility(View.GONE);
235         }
236     }
237 
238     /**
239      * Binds the avatar icon to the image view. If we don't want to show the image, hides the
240      * image view.
241      */
bindIconToView(boolean showImage, RecipientEntry entry, ImageView view, AdapterType type)242     protected void bindIconToView(boolean showImage, RecipientEntry entry, ImageView view,
243         AdapterType type) {
244         if (view == null) {
245             return;
246         }
247 
248         if (showImage) {
249             switch (type) {
250                 case BASE_RECIPIENT:
251                     byte[] photoBytes = entry.getPhotoBytes();
252                     if (photoBytes != null && photoBytes.length > 0) {
253                         final Bitmap photo = BitmapFactory.decodeByteArray(photoBytes, 0,
254                             photoBytes.length);
255                         view.setImageBitmap(photo);
256                     } else {
257                         view.setImageResource(getDefaultPhotoResId());
258                     }
259                     break;
260                 case RECIPIENT_ALTERNATES:
261                     Uri thumbnailUri = entry.getPhotoThumbnailUri();
262                     if (thumbnailUri != null) {
263                         // TODO: see if this needs to be done outside the main thread
264                         // as it may be too slow to get immediately.
265                         view.setImageURI(thumbnailUri);
266                     } else {
267                         view.setImageResource(getDefaultPhotoResId());
268                     }
269                     break;
270                 case SINGLE_RECIPIENT:
271                 default:
272                     break;
273             }
274             view.setVisibility(View.VISIBLE);
275         } else {
276             view.setVisibility(View.GONE);
277         }
278     }
279 
bindDrawableToDeleteView(final StateListDrawable drawable, String recipient, ImageView view)280     protected void bindDrawableToDeleteView(final StateListDrawable drawable, String recipient,
281             ImageView view) {
282         if (view == null) {
283             return;
284         }
285         if (drawable == null) {
286             view.setVisibility(View.GONE);
287         } else {
288             final Resources res = mContext.getResources();
289             view.setImageDrawable(drawable);
290             view.setContentDescription(
291                     res.getString(R.string.dropdown_delete_button_desc, recipient));
292             if (mDeleteListener != null) {
293                 view.setOnClickListener(new View.OnClickListener() {
294                     @Override
295                     public void onClick(View view) {
296                         if (drawable.getCurrent() != null) {
297                             mDeleteListener.onChipDelete();
298                         }
299                     }
300                 });
301             }
302         }
303     }
304 
bindIndicatorToView( @rawableRes int indicatorIconId, String indicatorText, TextView view)305     protected void bindIndicatorToView(
306             @DrawableRes int indicatorIconId, String indicatorText, TextView view) {
307         if (view != null) {
308             if (indicatorText != null || indicatorIconId != 0) {
309                 view.setText(indicatorText);
310                 view.setVisibility(View.VISIBLE);
311                 final Drawable indicatorIcon;
312                 if (indicatorIconId != 0) {
313                     indicatorIcon = mContext.getDrawable(indicatorIconId).mutate();
314                     indicatorIcon.setColorFilter(Color.WHITE, PorterDuff.Mode.SRC_IN);
315                 } else {
316                     indicatorIcon = null;
317                 }
318                 view.setCompoundDrawablesRelativeWithIntrinsicBounds(
319                         indicatorIcon, null, null, null);
320             } else {
321                 view.setVisibility(View.GONE);
322             }
323         }
324     }
325 
bindPermissionRequestDismissView(ImageView view)326     protected void bindPermissionRequestDismissView(ImageView view) {
327         if (view == null) {
328             return;
329         }
330         view.setOnClickListener(new OnClickListener() {
331             @Override
332             public void onClick(View v) {
333                 if (mPermissionRequestDismissedListener != null) {
334                     mPermissionRequestDismissedListener.onPermissionRequestDismissed();
335                 }
336             }
337         });
338     }
339 
setViewVisibility(View view, int visibility)340     protected void setViewVisibility(View view, int visibility) {
341         if (view != null) {
342             view.setVisibility(visibility);
343         }
344     }
345 
getDestinationType(RecipientEntry entry)346     protected CharSequence getDestinationType(RecipientEntry entry) {
347         return mQuery.getTypeLabel(mContext.getResources(), entry.getDestinationType(),
348             entry.getDestinationLabel()).toString().toUpperCase();
349     }
350 
351     /**
352      * Returns a layout id for each item inside auto-complete list.
353      *
354      * Each View must contain two TextViews (for display name and destination) and one ImageView
355      * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
356      * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
357      */
getItemLayoutResId(AdapterType type)358     protected @LayoutRes int getItemLayoutResId(AdapterType type) {
359         switch (type) {
360             case BASE_RECIPIENT:
361                 return R.layout.chips_autocomplete_recipient_dropdown_item;
362             case RECIPIENT_ALTERNATES:
363                 return R.layout.chips_recipient_dropdown_item;
364             default:
365                 return R.layout.chips_recipient_dropdown_item;
366         }
367     }
368 
369     /**
370      * Returns a layout id for each item inside alternate auto-complete list.
371      *
372      * Each View must contain two TextViews (for display name and destination) and one ImageView
373      * (for photo). Ids for those should be available via {@link #getDisplayNameResId()},
374      * {@link #getDestinationResId()}, and {@link #getPhotoResId()}.
375      */
getAlternateItemLayoutResId(AdapterType type)376     protected @LayoutRes int getAlternateItemLayoutResId(AdapterType type) {
377         switch (type) {
378             case BASE_RECIPIENT:
379                 return R.layout.chips_autocomplete_recipient_dropdown_item;
380             case RECIPIENT_ALTERNATES:
381                 return R.layout.chips_recipient_dropdown_item;
382             default:
383                 return R.layout.chips_recipient_dropdown_item;
384         }
385     }
386 
387     /**
388      * Returns a resource ID representing an image which should be shown when ther's no relevant
389      * photo is available.
390      */
getDefaultPhotoResId()391     protected @DrawableRes int getDefaultPhotoResId() {
392         return R.drawable.ic_contact_picture;
393     }
394 
395     /**
396      * Returns an id for the ViewGroup in an item View that contains the person ui elements.
397      */
getPersonGroupResId()398     protected @IdRes int getPersonGroupResId() {
399         return R.id.chip_person_wrapper;
400     }
401 
402     /**
403      * Returns an id for TextView in an item View for showing a display name. By default
404      * {@link android.R.id#title} is returned.
405      */
getDisplayNameResId()406     protected @IdRes int getDisplayNameResId() {
407         return android.R.id.title;
408     }
409 
410     /**
411      * Returns an id for TextView in an item View for showing a destination
412      * (an email address or a phone number).
413      * By default {@link android.R.id#text1} is returned.
414      */
getDestinationResId()415     protected @IdRes int getDestinationResId() {
416         return android.R.id.text1;
417     }
418 
419     /**
420      * Returns an id for TextView in an item View for showing the type of the destination.
421      * By default {@link android.R.id#text2} is returned.
422      */
getDestinationTypeResId()423     protected @IdRes int getDestinationTypeResId() {
424         return android.R.id.text2;
425     }
426 
427     /**
428      * Returns an id for ImageView in an item View for showing photo image for a person. In default
429      * {@link android.R.id#icon} is returned.
430      */
getPhotoResId()431     protected @IdRes int getPhotoResId() {
432         return android.R.id.icon;
433     }
434 
435     /**
436      * Returns an id for ImageView in an item View for showing the delete button. In default
437      * {@link android.R.id#icon1} is returned.
438      */
getDeleteResId()439     protected @IdRes int getDeleteResId() { return android.R.id.icon1; }
440 
441     /**
442      * Returns an id for the ViewGroup in an item View that contains the request permission ui
443      * elements.
444      */
getPermissionGroupResId()445     protected @IdRes int getPermissionGroupResId() {
446         return R.id.chip_permission_wrapper;
447     }
448 
449     /**
450      * Returns an id for ImageView in an item View for dismissing the permission request. In default
451      * {@link android.R.id#icon2} is returned.
452      */
getPermissionRequestDismissResId()453     protected @IdRes int getPermissionRequestDismissResId() {
454         return android.R.id.icon2;
455     }
456 
457     /**
458      * Given a constraint and a recipient entry, tries to find the constraint in the name and
459      * destination in the recipient entry. A foreground font color style will be applied to the
460      * section that matches the constraint. As soon as a match has been found, no further matches
461      * are attempted.
462      *
463      * @param constraint A string that we will attempt to find within the results.
464      * @param entry The recipient entry to style results for.
465      *
466      * @return An array of CharSequences, the length determined by the length of results. Each
467      *     CharSequence will either be a styled SpannableString or just the input String.
468      */
getStyledResults(@ullable String constraint, RecipientEntry entry)469     protected CharSequence[] getStyledResults(@Nullable String constraint, RecipientEntry entry) {
470       return getStyledResults(constraint, entry.getDisplayName(), entry.getDestination());
471     }
472 
473     /**
474      * Given a constraint and results, tries to find the constraint in those results, one at a time.
475      * A foreground font color style will be applied to the section that matches the constraint. As
476      * soon as a match has been found, no further matches are attempted.
477      *
478      * @param constraint A string that we will attempt to find within the results.
479      * @param results Strings that may contain the constraint. The order given is the order used to
480      *     search for the constraint.
481      *
482      * @return An array of CharSequences, the length determined by the length of results. Each
483      *     CharSequence will either be a styled SpannableString or just the input String.
484      */
getStyledResults(@ullable String constraint, String... results)485     protected CharSequence[] getStyledResults(@Nullable String constraint, String... results) {
486         if (isAllWhitespace(constraint)) {
487             return results;
488         }
489 
490         CharSequence[] styledResults = new CharSequence[results.length];
491         boolean foundMatch = false;
492         for (int i = 0; i < results.length; i++) {
493             String result = results[i];
494             if (result == null) {
495                 continue;
496             }
497 
498             if (!foundMatch) {
499                 int index = result.toLowerCase().indexOf(constraint.toLowerCase());
500                 if (index != -1) {
501                     SpannableStringBuilder styled = SpannableStringBuilder.valueOf(result);
502                     ForegroundColorSpan highlightSpan =
503                             new ForegroundColorSpan(mContext.getResources().getColor(
504                                     R.color.chips_dropdown_text_highlighted));
505                     styled.setSpan(highlightSpan,
506                             index, index + constraint.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
507                     styledResults[i] = styled;
508                     foundMatch = true;
509                     continue;
510                 }
511             }
512             styledResults[i] = result;
513         }
514         return styledResults;
515     }
516 
isAllWhitespace(@ullable String string)517     private static boolean isAllWhitespace(@Nullable String string) {
518         if (TextUtils.isEmpty(string)) {
519             return true;
520         }
521 
522         for (int i = 0; i < string.length(); ++i) {
523             if (!Character.isWhitespace(string.charAt(i))) {
524                 return false;
525             }
526         }
527 
528         return true;
529     }
530 
531     /**
532      * A holder class the view. Uses the getters in DropdownChipLayouter to find the id of the
533      * corresponding views.
534      */
535     protected class ViewHolder {
536         public final ViewGroup personViewGroup;
537         public final TextView displayNameView;
538         public final TextView destinationView;
539         public final TextView destinationTypeView;
540         public final TextView indicatorView;
541         public final ImageView imageView;
542         public final ImageView deleteView;
543         public final View topDivider;
544         public final View bottomDivider;
545         public final View permissionBottomDivider;
546 
547         public final ViewGroup permissionViewGroup;
548         public final ImageView permissionRequestDismissView;
549 
ViewHolder(View view)550         public ViewHolder(View view) {
551             personViewGroup = (ViewGroup) view.findViewById(getPersonGroupResId());
552             displayNameView = (TextView) view.findViewById(getDisplayNameResId());
553             destinationView = (TextView) view.findViewById(getDestinationResId());
554             destinationTypeView = (TextView) view.findViewById(getDestinationTypeResId());
555             imageView = (ImageView) view.findViewById(getPhotoResId());
556             deleteView = (ImageView) view.findViewById(getDeleteResId());
557             topDivider = view.findViewById(R.id.chip_autocomplete_top_divider);
558 
559             bottomDivider = view.findViewById(R.id.chip_autocomplete_bottom_divider);
560             permissionBottomDivider = view.findViewById(R.id.chip_permission_bottom_divider);
561 
562             indicatorView = (TextView) view.findViewById(R.id.chip_indicator_text);
563 
564             permissionViewGroup = (ViewGroup) view.findViewById(getPermissionGroupResId());
565             permissionRequestDismissView =
566                     (ImageView) view.findViewById(getPermissionRequestDismissResId());
567         }
568     }
569 }
570