1 /*
2 
3  * Copyright (C) 2011 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.ex.chips;
19 
20 import android.annotation.TargetApi;
21 import android.app.Activity;
22 import android.app.AlertDialog;
23 import android.app.DialogFragment;
24 import android.content.ClipData;
25 import android.content.ClipDescription;
26 import android.content.ClipboardManager;
27 import android.content.Context;
28 import android.content.DialogInterface;
29 import android.content.res.Resources;
30 import android.content.res.TypedArray;
31 import android.graphics.Bitmap;
32 import android.graphics.BitmapFactory;
33 import android.graphics.BitmapShader;
34 import android.graphics.Canvas;
35 import android.graphics.Color;
36 import android.graphics.Matrix;
37 import android.graphics.Paint;
38 import android.graphics.Paint.Style;
39 import android.graphics.Point;
40 import android.graphics.Rect;
41 import android.graphics.RectF;
42 import android.graphics.Shader.TileMode;
43 import android.graphics.drawable.BitmapDrawable;
44 import android.graphics.drawable.Drawable;
45 import android.graphics.drawable.StateListDrawable;
46 import android.os.AsyncTask;
47 import android.os.Build;
48 import android.os.Bundle;
49 import android.os.Handler;
50 import android.os.Looper;
51 import android.os.Message;
52 import android.os.Parcelable;
53 import androidx.annotation.NonNull;
54 import android.text.Editable;
55 import android.text.InputType;
56 import android.text.Layout;
57 import android.text.Spannable;
58 import android.text.SpannableString;
59 import android.text.SpannableStringBuilder;
60 import android.text.Spanned;
61 import android.text.TextPaint;
62 import android.text.TextUtils;
63 import android.text.TextWatcher;
64 import android.text.method.QwertyKeyListener;
65 import android.text.util.Rfc822Token;
66 import android.text.util.Rfc822Tokenizer;
67 import android.util.AttributeSet;
68 import android.util.Log;
69 import android.view.ActionMode;
70 import android.view.ActionMode.Callback;
71 import android.view.DragEvent;
72 import android.view.GestureDetector;
73 import android.view.KeyEvent;
74 import android.view.LayoutInflater;
75 import android.view.Menu;
76 import android.view.MenuItem;
77 import android.view.MotionEvent;
78 import android.view.View;
79 import android.view.ViewParent;
80 import android.view.accessibility.AccessibilityEvent;
81 import android.view.accessibility.AccessibilityManager;
82 import android.view.inputmethod.EditorInfo;
83 import android.view.inputmethod.InputConnection;
84 import android.widget.AdapterView;
85 import android.widget.AdapterView.OnItemClickListener;
86 import android.widget.Filterable;
87 import android.widget.ListAdapter;
88 import android.widget.ListPopupWindow;
89 import android.widget.ListView;
90 import android.widget.MultiAutoCompleteTextView;
91 import android.widget.PopupWindow;
92 import android.widget.ScrollView;
93 import android.widget.TextView;
94 
95 import com.android.ex.chips.DropdownChipLayouter.PermissionRequestDismissedListener;
96 import com.android.ex.chips.RecipientAlternatesAdapter.RecipientMatchCallback;
97 import com.android.ex.chips.recipientchip.DrawableRecipientChip;
98 import com.android.ex.chips.recipientchip.InvisibleRecipientChip;
99 import com.android.ex.chips.recipientchip.ReplacementDrawableSpan;
100 import com.android.ex.chips.recipientchip.VisibleRecipientChip;
101 
102 import java.util.ArrayList;
103 import java.util.Arrays;
104 import java.util.Collections;
105 import java.util.Comparator;
106 import java.util.HashSet;
107 import java.util.List;
108 import java.util.Map;
109 import java.util.Set;
110 
111 /**
112  * RecipientEditTextView is an auto complete text view for use with applications
113  * that use the new Chips UI for addressing a message to recipients.
114  */
115 public class RecipientEditTextView extends MultiAutoCompleteTextView implements
116         OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener,
117         GestureDetector.OnGestureListener, TextView.OnEditorActionListener,
118         DropdownChipLayouter.ChipDeleteListener, PermissionRequestDismissedListener {
119     private static final String TAG = "RecipientEditTextView";
120 
121     private static final char COMMIT_CHAR_COMMA = ',';
122     private static final char COMMIT_CHAR_SEMICOLON = ';';
123     private static final char COMMIT_CHAR_SPACE = ' ';
124     private static final String SEPARATOR = String.valueOf(COMMIT_CHAR_COMMA)
125             + String.valueOf(COMMIT_CHAR_SPACE);
126 
127     private static final int DISMISS = "dismiss".hashCode();
128     private static final long DISMISS_DELAY = 300;
129 
130     // TODO: get correct number/ algorithm from with UX.
131     // Visible for testing.
132     /*package*/ static final int CHIP_LIMIT = 2;
133 
134     private static final int MAX_CHIPS_PARSED = 50;
135     public static final String STATE_TEXT_VIEW = "savedTextView";
136     public static final String STATE_CURRENT_WARNING_TEXT = "savedCurrentWarningText";
137 
138     private int mUnselectedChipTextColor;
139     private int mUnselectedChipBackgroundColor;
140 
141     // Work variables to avoid re-allocation on every typed character.
142     private final Rect mRect = new Rect();
143     private final int[] mCoords = new int[2];
144 
145     // Resources for displaying chips.
146     private Drawable mChipBackground = null;
147     private Drawable mChipDelete = null;
148     private Drawable mInvalidChipBackground;
149 
150     // Possible attr overrides
151     private float mChipHeight;
152     private float mChipFontSize;
153     private float mLineSpacingExtra;
154     private int mChipTextStartPadding;
155     private int mChipTextEndPadding;
156     private final int mTextHeight;
157     private boolean mDisableDelete;
158     private int mMaxLines;
159     private int mWarningIconHeight;
160 
161     /**
162      * Enumerator for avatar position. See attr.xml for more details.
163      * 0 for end, 1 for start.
164      */
165     private int mAvatarPosition;
166     private static final int AVATAR_POSITION_END = 0;
167     private static final int AVATAR_POSITION_START = 1;
168 
169     private Paint mWorkPaint = new Paint();
170 
171     private Tokenizer mTokenizer;
172     private Validator mValidator;
173     private Handler mHandler;
174     private TextWatcher mTextWatcher;
175     protected DropdownChipLayouter mDropdownChipLayouter;
176 
177     private View mDropdownAnchor = this;
178     private ListPopupWindow mAlternatesPopup;
179     private ListPopupWindow mAddressPopup;
180     private View mAlternatePopupAnchor;
181     private OnItemClickListener mAlternatesListener;
182 
183     private DrawableRecipientChip mSelectedChip;
184     private Bitmap mDefaultContactPhoto;
185     private Bitmap mWarningIcon;
186     private ReplacementDrawableSpan mMoreChip;
187     private TextView mMoreItem;
188 
189     private int mCurrentSuggestionCount;
190 
191     // VisibleForTesting
192     final ArrayList<String> mPendingChips = new ArrayList<String>();
193 
194     private int mPendingChipsCount = 0;
195     private int mCheckedItem;
196     private boolean mNoChipMode = false;
197     private boolean mShouldShrink = true;
198     private boolean mRequiresShrinkWhenNotGone = false;
199 
200     // VisibleForTesting
201     ArrayList<DrawableRecipientChip> mTemporaryRecipients;
202 
203     private ArrayList<DrawableRecipientChip> mHiddenSpans;
204 
205     // Chip copy fields.
206     private GestureDetector mGestureDetector;
207 
208     // Obtain the enclosing scroll view, if it exists, so that the view can be
209     // scrolled to show the last line of chips content.
210     private ScrollView mScrollView;
211     private boolean mTriedGettingScrollView;
212     private boolean mDragEnabled = false;
213 
214     private boolean mAttachedToWindow;
215 
216     private final Runnable mAddTextWatcher = new Runnable() {
217         @Override
218         public void run() {
219             if (mTextWatcher == null) {
220                 mTextWatcher = new RecipientTextWatcher();
221                 addTextChangedListener(mTextWatcher);
222             }
223         }
224     };
225 
226     private IndividualReplacementTask mIndividualReplacements;
227 
228     private Runnable mHandlePendingChips = new Runnable() {
229 
230         @Override
231         public void run() {
232             handlePendingChips();
233         }
234 
235     };
236 
237     private Runnable mDelayedShrink = new Runnable() {
238 
239         @Override
240         public void run() {
241             shrink();
242         }
243 
244     };
245 
246     private RecipientEntryItemClickedListener mRecipientEntryItemClickedListener;
247 
248     private RecipientChipAddedListener mRecipientChipAddedListener;
249     private RecipientChipDeletedListener mRecipientChipDeletedListener;
250 
251     // A set of recipient addresses that are untrusted because they are outside of the user's
252     // domain. We will show a warning for these addresses in the recipient chips.
253     private Set<String> mUntrustedAddresses = new HashSet<>();
254 
255     private String mWarningTextTemplate = "";
256     private String mWarningTitle = "";
257     // Text of the warning dialog currently being displayed. Empty if no dialog currently displayed.
258     private String mCurrentWarningText = "";
259 
260     /**
261      * Sets this recipient edit text view to display warning icons in chips for the given addresses.
262      *
263      * @param untrustedAddresses The addresses to display warning icons for.
264      * @param warningIcon The icon to show for each address.
265      * @param warningIconHeight Height of the warning icon in
266      * @param warningTextTemplate Text to display when warning icon is clicked.
267      * @param warningTitle Title to display for text when warning icon is clicked.
268      */
setUntrustedAddressWarning( Set<String> untrustedAddresses, Bitmap warningIcon, int warningIconHeight, String warningTextTemplate, String warningTitle)269     public void setUntrustedAddressWarning(
270             Set<String> untrustedAddresses,
271             Bitmap warningIcon,
272             int warningIconHeight,
273             String warningTextTemplate,
274             String warningTitle) {
275         mUntrustedAddresses = untrustedAddresses;
276         mWarningIcon = warningIcon;
277         mWarningIconHeight = warningIconHeight;
278         mWarningTextTemplate = warningTextTemplate;
279         mWarningTitle = warningTitle;
280     }
281 
282     public interface RecipientEntryItemClickedListener {
283         /**
284          * Callback that occurs whenever an auto-complete suggestion is clicked.
285          * @param charactersTyped the number of characters typed by the user to provide the
286          *                        auto-complete suggestions.
287          * @param position the position in the dropdown list that the user clicked
288          */
onRecipientEntryItemClicked(int charactersTyped, int position)289         void onRecipientEntryItemClicked(int charactersTyped, int position);
290     }
291 
292     private PermissionsRequestItemClickedListener mPermissionsRequestItemClickedListener;
293 
294     /**
295      * Listener for handling clicks on the {@link RecipientEntry} that have
296      * {@link RecipientEntry#ENTRY_TYPE_PERMISSION_REQUEST} type.
297      */
298     public interface PermissionsRequestItemClickedListener {
299 
300         /**
301          * Callback that occurs when user clicks the item that asks user to grant permissions to
302          * the app.
303          *
304          * @param view View that asks for permission.
305          */
onPermissionsRequestItemClicked(RecipientEditTextView view, String[] permissions)306         void onPermissionsRequestItemClicked(RecipientEditTextView view, String[] permissions);
307 
308         /**
309          * Callback that occurs when user dismisses the item that asks user to grant permissions to
310          * the app.
311          */
onPermissionRequestDismissed()312         void onPermissionRequestDismissed();
313     }
314 
315     /**
316      * Listener for handling deletion of chips in the recipient edit text.
317      */
318     public interface RecipientChipDeletedListener {
319         /**
320          * Callback that occurs when a chip is deleted.
321          * @param entry RecipientEntry that contains information about the chip.
322          */
onRecipientChipDeleted(RecipientEntry entry)323         void onRecipientChipDeleted(RecipientEntry entry);
324     }
325 
326     /**
327      * Listener for handling addition of chips in the recipient edit text.
328      */
329     public interface RecipientChipAddedListener {
330         /**
331          * Callback that occurs when a chip is added.
332          *
333          * @param entry RecipientEntry that contains information about the chip.
334          */
onRecipientChipAdded(RecipientEntry entry)335         void onRecipientChipAdded(RecipientEntry entry);
336     }
337 
RecipientEditTextView(Context context, AttributeSet attrs)338     public RecipientEditTextView(Context context, AttributeSet attrs) {
339         super(context, attrs);
340         setChipDimensions(context, attrs);
341         mTextHeight = calculateTextHeight();
342         mAlternatesPopup = new ListPopupWindow(context);
343         setupPopupWindow(mAlternatesPopup);
344         mAddressPopup = new ListPopupWindow(context);
345         setupPopupWindow(mAddressPopup);
346         mAlternatesListener = new OnItemClickListener() {
347             @Override
348             public void onItemClick(AdapterView<?> adapterView,View view, int position,
349                     long rowId) {
350                 if(mAlternatesPopup.getListView() != null){
351                     mAlternatesPopup.getListView().setOnItemClickListener(null);
352                 }
353                 mAlternatesPopup.setOnItemClickListener(null);
354                 replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter())
355                         .getRecipientEntry(position));
356                 Message delayed = Message.obtain(mHandler, DISMISS);
357                 delayed.obj = mAlternatesPopup;
358                 mHandler.sendMessageDelayed(delayed, DISMISS_DELAY);
359                 clearComposingText();
360             }
361         };
362         setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
363         setOnItemClickListener(this);
364         setCustomSelectionActionModeCallback(this);
365         mHandler = new Handler() {
366             @Override
367             public void handleMessage(Message msg) {
368                 if (msg.what == DISMISS) {
369                     ((ListPopupWindow) msg.obj).dismiss();
370                     return;
371                 }
372                 super.handleMessage(msg);
373             }
374         };
375         mTextWatcher = new RecipientTextWatcher();
376         addTextChangedListener(mTextWatcher);
377         mGestureDetector = new GestureDetector(context, this);
378         setOnEditorActionListener(this);
379 
380         setDropdownChipLayouter(new DropdownChipLayouter(LayoutInflater.from(context), context));
381     }
382 
setupPopupWindow(ListPopupWindow popup)383     private void setupPopupWindow(ListPopupWindow popup) {
384         popup.setOnDismissListener(new PopupWindow.OnDismissListener() {
385             @Override
386             public void onDismiss() {
387                 clearSelectedChip();
388             }
389         });
390     }
391 
calculateTextHeight()392     private int calculateTextHeight() {
393         final TextPaint paint = getPaint();
394 
395         mRect.setEmpty();
396         // First measure the bounds of a sample text.
397         final String textHeightSample = "a";
398         paint.getTextBounds(textHeightSample, 0, textHeightSample.length(), mRect);
399 
400         mRect.left = 0;
401         mRect.right = 0;
402 
403         return mRect.height();
404     }
405 
setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter)406     public void setDropdownChipLayouter(DropdownChipLayouter dropdownChipLayouter) {
407         mDropdownChipLayouter = dropdownChipLayouter;
408         mDropdownChipLayouter.setDeleteListener(this);
409         mDropdownChipLayouter.setPermissionRequestDismissedListener(this);
410     }
411 
setRecipientEntryItemClickedListener(RecipientEntryItemClickedListener listener)412     public void setRecipientEntryItemClickedListener(RecipientEntryItemClickedListener listener) {
413         mRecipientEntryItemClickedListener = listener;
414     }
415 
setPermissionsRequestItemClickedListener( PermissionsRequestItemClickedListener listener)416     public void setPermissionsRequestItemClickedListener(
417             PermissionsRequestItemClickedListener listener) {
418         mPermissionsRequestItemClickedListener = listener;
419     }
420 
setRecipientChipAddedListener(RecipientChipAddedListener listener)421     public void setRecipientChipAddedListener(RecipientChipAddedListener listener) {
422         mRecipientChipAddedListener = listener;
423     }
424 
setRecipientChipDeletedListener(RecipientChipDeletedListener listener)425     public void setRecipientChipDeletedListener(RecipientChipDeletedListener listener) {
426         mRecipientChipDeletedListener = listener;
427     }
428 
429     @Override
onDetachedFromWindow()430     protected void onDetachedFromWindow() {
431         super.onDetachedFromWindow();
432         mAttachedToWindow = false;
433     }
434 
435     @Override
onAttachedToWindow()436     protected void onAttachedToWindow() {
437         super.onAttachedToWindow();
438         mAttachedToWindow = true;
439 
440         final int anchorId = getDropDownAnchor();
441         if (anchorId != View.NO_ID) {
442             mDropdownAnchor = getRootView().findViewById(anchorId);
443         }
444     }
445 
446     @Override
setDropDownAnchor(int anchorId)447     public void setDropDownAnchor(int anchorId) {
448         super.setDropDownAnchor(anchorId);
449         if (anchorId != View.NO_ID) {
450           mDropdownAnchor = getRootView().findViewById(anchorId);
451         }
452     }
453 
454     @Override
onEditorAction(TextView view, int action, KeyEvent keyEvent)455     public boolean onEditorAction(TextView view, int action, KeyEvent keyEvent) {
456         if (action == EditorInfo.IME_ACTION_DONE) {
457             if (commitDefault()) {
458                 return true;
459             }
460             if (mSelectedChip != null) {
461                 clearSelectedChip();
462                 return true;
463             } else if (hasFocus()) {
464                 if (focusNext()) {
465                     return true;
466                 }
467             }
468         }
469         return false;
470     }
471 
472     @Override
onCreateInputConnection(@onNull EditorInfo outAttrs)473     public InputConnection onCreateInputConnection(@NonNull EditorInfo outAttrs) {
474         InputConnection connection = super.onCreateInputConnection(outAttrs);
475         int imeActions = outAttrs.imeOptions&EditorInfo.IME_MASK_ACTION;
476         if ((imeActions&EditorInfo.IME_ACTION_DONE) != 0) {
477             // clear the existing action
478             outAttrs.imeOptions ^= imeActions;
479             // set the DONE action
480             outAttrs.imeOptions |= EditorInfo.IME_ACTION_DONE;
481         }
482         if ((outAttrs.imeOptions&EditorInfo.IME_FLAG_NO_ENTER_ACTION) != 0) {
483             outAttrs.imeOptions &= ~EditorInfo.IME_FLAG_NO_ENTER_ACTION;
484         }
485 
486         outAttrs.actionId = EditorInfo.IME_ACTION_DONE;
487 
488         // Custom action labels are discouraged in L; a checkmark icon is shown in place of the
489         // custom text in this case.
490         outAttrs.actionLabel = Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP ? null :
491             getContext().getString(R.string.action_label);
492         return connection;
493     }
494 
getLastChip()495     /*package*/ DrawableRecipientChip getLastChip() {
496         DrawableRecipientChip last = null;
497         DrawableRecipientChip[] chips = getSortedRecipients();
498         if (chips != null && chips.length > 0) {
499             last = chips[chips.length - 1];
500         }
501         return last;
502     }
503 
504     /**
505      * @return The list of {@link RecipientEntry}s that have been selected by the user.
506      */
getSelectedRecipients()507     public List<RecipientEntry> getSelectedRecipients() {
508         DrawableRecipientChip[] chips =
509                 getText().getSpans(0, getText().length(), DrawableRecipientChip.class);
510         List<RecipientEntry> results = new ArrayList<RecipientEntry>();
511         if (chips == null) {
512             return results;
513         }
514 
515         for (DrawableRecipientChip c : chips) {
516             results.add(c.getEntry());
517         }
518 
519         return results;
520     }
521 
522     /**
523      * @return The list of {@link RecipientEntry}s that have been selected by the user and also
524      *         hidden due to {@link #mMoreChip} span.
525      */
getAllRecipients()526     public List<RecipientEntry> getAllRecipients() {
527         List<RecipientEntry> results = getSelectedRecipients();
528 
529         if (mHiddenSpans != null) {
530             for (DrawableRecipientChip chip : mHiddenSpans) {
531                 results.add(chip.getEntry());
532             }
533         }
534 
535         return results;
536     }
537 
538     @Override
onSelectionChanged(int start, int end)539     public void onSelectionChanged(int start, int end) {
540         // When selection changes, see if it is inside the chips area.
541         // If so, move the cursor back after the chips again.
542         // Only exception is when we change the selection due to a selected chip.
543         DrawableRecipientChip last = getLastChip();
544         if (mSelectedChip == null && last != null && start < getSpannable().getSpanEnd(last)) {
545             // Grab the last chip and set the cursor to after it.
546             setSelection(Math.min(getSpannable().getSpanEnd(last) + 1, getText().length()));
547         }
548         super.onSelectionChanged(start, end);
549     }
550 
551     @Override
onRestoreInstanceState(Parcelable state)552     public void onRestoreInstanceState(Parcelable state) {
553         Bundle savedInstanceState = (Bundle) state;
554         if (!TextUtils.isEmpty(getText())) {
555             super.onRestoreInstanceState(null);
556         } else {
557             super.onRestoreInstanceState(
558                     savedInstanceState.getParcelable(STATE_TEXT_VIEW));
559         }
560         String savedWarningText = savedInstanceState.getString(
561             STATE_CURRENT_WARNING_TEXT);
562         if (!savedWarningText.isEmpty()) {
563             showWarningDialog(savedWarningText);
564         }
565     }
566 
567     @Override
onSaveInstanceState()568     public Parcelable onSaveInstanceState() {
569         // If the user changes orientation while they are editing, just roll back the selection.
570         clearSelectedChip();
571         Bundle savedInstanceState = new Bundle();
572         savedInstanceState.putParcelable(STATE_TEXT_VIEW, super.onSaveInstanceState());
573         savedInstanceState.putString(STATE_CURRENT_WARNING_TEXT, mCurrentWarningText);
574         return savedInstanceState;
575     }
576 
577     /**
578      * Convenience method: Append the specified text slice to the TextView's
579      * display buffer, upgrading it to BufferType.EDITABLE if it was
580      * not already editable. Commas are excluded as they are added automatically
581      * by the view.
582      */
583     @Override
append(CharSequence text, int start, int end)584     public void append(CharSequence text, int start, int end) {
585         // We don't care about watching text changes while appending.
586         if (mTextWatcher != null) {
587             removeTextChangedListener(mTextWatcher);
588         }
589         super.append(text, start, end);
590         if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) {
591             String displayString = text.toString();
592 
593             if (!displayString.trim().endsWith(String.valueOf(COMMIT_CHAR_COMMA))) {
594                 // We have no separator, so we should add it
595                 super.append(SEPARATOR, 0, SEPARATOR.length());
596                 displayString += SEPARATOR;
597             }
598 
599             if (!TextUtils.isEmpty(displayString)
600                     && TextUtils.getTrimmedLength(displayString) > 0) {
601                 mPendingChipsCount++;
602                 mPendingChips.add(displayString);
603             }
604         }
605         // Put a message on the queue to make sure we ALWAYS handle pending
606         // chips.
607         if (mPendingChipsCount > 0) {
608             postHandlePendingChips();
609         }
610         mHandler.post(mAddTextWatcher);
611     }
612 
613     @Override
onFocusChanged(boolean hasFocus, int direction, Rect previous)614     public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
615         super.onFocusChanged(hasFocus, direction, previous);
616         if (!hasFocus) {
617             shrink();
618         } else {
619             expand();
620         }
621     }
622 
623     @Override
setAdapter(@onNull T adapter)624     public <T extends ListAdapter & Filterable> void setAdapter(@NonNull T adapter) {
625         super.setAdapter(adapter);
626         BaseRecipientAdapter baseAdapter = (BaseRecipientAdapter) adapter;
627         baseAdapter.registerUpdateObserver(new BaseRecipientAdapter.EntriesUpdatedObserver() {
628             @Override
629             public void onChanged(List<RecipientEntry> entries) {
630                 int suggestionCount = entries == null ? 0 : entries.size();
631 
632                 // Scroll the chips field to the top of the screen so
633                 // that the user can see as many results as possible.
634                 if (entries != null && entries.size() > 0) {
635                     scrollBottomIntoView();
636                     // Here the current suggestion count is still the old one since we update
637                     // the count at the bottom of this function.
638                     if (mCurrentSuggestionCount == 0) {
639                         // Announce the new number of possible choices for accessibility.
640                         announceForAccessibilityCompat(
641                                 getSuggestionDropdownOpenedVerbalization(suggestionCount));
642                     }
643                 }
644 
645                 // Is the dropdown closing?
646                 if ((entries == null || entries.size() == 0)
647                         // Here the current suggestion count is still the old one since we update
648                         // the count at the bottom of this function.
649                         && mCurrentSuggestionCount != 0
650                         // If there is no text, there's no need to know if no suggestions are
651                         // available.
652                         && getText().length() > 0) {
653                     announceForAccessibilityCompat(getResources().getString(
654                             R.string.accessbility_suggestion_dropdown_closed));
655                 }
656 
657                 if ((entries != null)
658                         && (entries.size() == 1)
659                         && (entries.get(0).getEntryType() ==
660                                 RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST)) {
661                     // Do nothing; showing a single permissions entry. Resizing not required.
662                 } else {
663                     // Set the dropdown height to be the remaining height from the anchor to the
664                     // bottom.
665                     mDropdownAnchor.getLocationOnScreen(mCoords);
666                     getWindowVisibleDisplayFrame(mRect);
667                     setDropDownHeight(mRect.bottom - mCoords[1] - mDropdownAnchor.getHeight() -
668                             getDropDownVerticalOffset());
669                 }
670 
671                 mCurrentSuggestionCount = suggestionCount;
672             }
673         });
674         baseAdapter.setDropdownChipLayouter(mDropdownChipLayouter);
675     }
676 
677     /**
678      * Return the accessibility verbalization when the suggestion dropdown is opened.
679      */
getSuggestionDropdownOpenedVerbalization(int suggestionCount)680     public String getSuggestionDropdownOpenedVerbalization(int suggestionCount) {
681         return getResources().getString(R.string.accessbility_suggestion_dropdown_opened);
682     }
683 
684     @TargetApi(Build.VERSION_CODES.JELLY_BEAN)
announceForAccessibilityCompat(String text)685     private void announceForAccessibilityCompat(String text) {
686         final AccessibilityManager accessibilityManager =
687                 (AccessibilityManager) getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
688         final boolean isAccessibilityOn = accessibilityManager.isEnabled();
689 
690         if (isAccessibilityOn && Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN) {
691             final ViewParent parent = getParent();
692             if (parent != null) {
693                 AccessibilityEvent event = AccessibilityEvent.obtain(
694                         AccessibilityEvent.TYPE_ANNOUNCEMENT);
695                 onInitializeAccessibilityEvent(event);
696                 event.getText().add(text);
697                 event.setContentDescription(null);
698                 parent.requestSendAccessibilityEvent(this, event);
699             }
700         }
701     }
702 
scrollBottomIntoView()703     protected void scrollBottomIntoView() {
704         if (mScrollView != null && mShouldShrink) {
705             getLocationInWindow(mCoords);
706             // Desired position shows at least 1 line of chips below the action
707             // bar. We add excess padding to make sure this is always below other
708             // content.
709             final int height = getHeight();
710             final int currentPos = mCoords[1] + height;
711             mScrollView.getLocationInWindow(mCoords);
712             final int desiredPos = mCoords[1] + height / getLineCount();
713             if (currentPos > desiredPos) {
714                 mScrollView.scrollBy(0, currentPos - desiredPos);
715             }
716         }
717     }
718 
getScrollView()719     protected ScrollView getScrollView() {
720         return mScrollView;
721     }
722 
723     @Override
performValidation()724     public void performValidation() {
725         // Do nothing. Chips handles its own validation.
726     }
727 
shrink()728     private void shrink() {
729         if (mTokenizer == null) {
730             return;
731         }
732         long contactId = mSelectedChip != null ? mSelectedChip.getEntry().getContactId() : -1;
733         if (mSelectedChip != null && contactId != RecipientEntry.INVALID_CONTACT
734                 && (!isPhoneQuery() && contactId != RecipientEntry.GENERATED_CONTACT)) {
735             clearSelectedChip();
736         } else {
737             if (getWidth() <= 0) {
738                 mHandler.removeCallbacks(mDelayedShrink);
739 
740                 if (getVisibility() == GONE) {
741                     // We aren't going to have a width any time soon, so defer
742                     // this until we're not GONE.
743                     mRequiresShrinkWhenNotGone = true;
744                 } else {
745                     // We don't have the width yet which means the view hasn't been drawn yet
746                     // and there is no reason to attempt to commit chips yet.
747                     // This focus lost must be the result of an orientation change
748                     // or an initial rendering.
749                     // Re-post the shrink for later.
750                     mHandler.post(mDelayedShrink);
751                 }
752                 return;
753             }
754             // Reset any pending chips as they would have been handled
755             // when the field lost focus.
756             if (mPendingChipsCount > 0) {
757                 postHandlePendingChips();
758             } else {
759                 Editable editable = getText();
760                 int end = getSelectionEnd();
761                 int start = mTokenizer.findTokenStart(editable, end);
762                 DrawableRecipientChip[] chips =
763                         getSpannable().getSpans(start, end, DrawableRecipientChip.class);
764                 if ((chips == null || chips.length == 0)) {
765                     Editable text = getText();
766                     int whatEnd = mTokenizer.findTokenEnd(text, start);
767                     // This token was already tokenized, so skip past the ending token.
768                     if (whatEnd < text.length() && text.charAt(whatEnd) == ',') {
769                         whatEnd = movePastTerminators(whatEnd);
770                     }
771                     // In the middle of chip; treat this as an edit
772                     // and commit the whole token.
773                     int selEnd = getSelectionEnd();
774                     if (whatEnd != selEnd) {
775                         handleEdit(start, whatEnd);
776                     } else {
777                         commitChip(start, end, editable);
778                     }
779                 }
780             }
781             mHandler.post(mAddTextWatcher);
782         }
783         createMoreChip();
784     }
785 
expand()786     private void expand() {
787         if (mShouldShrink) {
788             setMaxLines(Integer.MAX_VALUE);
789         }
790         removeMoreChip();
791         setCursorVisible(true);
792         Editable text = getText();
793         setSelection(text != null && text.length() > 0 ? text.length() : 0);
794         // If there are any temporary chips, try replacing them now that the user
795         // has expanded the field.
796         if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) {
797             new RecipientReplacementTask().execute();
798             mTemporaryRecipients = null;
799         }
800     }
801 
ellipsizeText(CharSequence text, TextPaint paint, float maxWidth)802     private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) {
803         paint.setTextSize(mChipFontSize);
804         if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) {
805             Log.d(TAG, "Max width is negative: " + maxWidth);
806         }
807         return TextUtils.ellipsize(text, paint, maxWidth,
808                 TextUtils.TruncateAt.END);
809     }
810 
811     /**
812      * Creates a bitmap of the given contact on a selected chip.
813      *
814      * @param contact The recipient entry to pull data from.
815      * @param paint The paint to use to draw the bitmap.
816      */
createChipBitmap(RecipientEntry contact, TextPaint paint)817     private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint) {
818         paint.setColor(getDefaultChipTextColor(contact));
819         ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint,
820                 getChipBackground(contact), getDefaultChipBackgroundColor(contact));
821 
822         if (bitmapContainer.loadIcon) {
823             loadAvatarIcon(contact, bitmapContainer);
824         }
825         return bitmapContainer;
826     }
827 
createChipBitmap(RecipientEntry contact, TextPaint paint, Drawable overrideBackgroundDrawable, int backgroundColor)828     private ChipBitmapContainer createChipBitmap(RecipientEntry contact, TextPaint paint,
829             Drawable overrideBackgroundDrawable, int backgroundColor) {
830         final ChipBitmapContainer result = new ChipBitmapContainer();
831 
832         Drawable indicatorIcon = null;
833         int indicatorPadding = 0;
834         if (contact.getIndicatorIconId() != 0) {
835             indicatorIcon = getContext().getDrawable(contact.getIndicatorIconId());
836             indicatorIcon.setBounds(0, 0,
837                     indicatorIcon.getIntrinsicWidth(), indicatorIcon.getIntrinsicHeight());
838             indicatorPadding = indicatorIcon.getBounds().width() + mChipTextEndPadding;
839         }
840 
841         Rect backgroundPadding = new Rect();
842         if (overrideBackgroundDrawable != null) {
843             overrideBackgroundDrawable.getPadding(backgroundPadding);
844         }
845 
846         // Ellipsize the text so that it takes AT MOST the entire width of the
847         // autocomplete text entry area. Make sure to leave space for padding
848         // on the sides.
849         int height = (int) mChipHeight;
850         // Since the icon is a square, it's width is equal to the maximum height it can be inside
851         // the chip. Don't include iconWidth for invalid contacts and when not displaying photos.
852         boolean displayIcon = contact.isValid() && contact.shouldDisplayIcon();
853         int iconWidth = displayIcon ?
854                 height - backgroundPadding.top - backgroundPadding.bottom : 0;
855 
856         final boolean shouldDisplayWarningIcon = mUntrustedAddresses.contains(
857                 contact.getDestination());
858         final float warningIconWidth = shouldDisplayWarningIcon ? mWarningIconHeight : 0;
859         final float warningIconTopMargin = (mChipHeight - mWarningIconHeight) / 2f;
860         final float warningIconEndMargin = shouldDisplayWarningIcon ? mChipTextEndPadding : 0;
861 
862         float[] widths = new float[1];
863         paint.getTextWidths(" ", widths);
864         CharSequence ellipsizedText = ellipsizeText(createChipDisplayText(contact), paint,
865                 calculateAvailableWidth()
866                     - iconWidth
867                     - warningIconWidth
868                     - warningIconEndMargin
869                     - widths[0]
870                     - backgroundPadding.left
871                     - backgroundPadding.right
872                     - indicatorPadding);
873         int textWidth = (int) paint.measureText(ellipsizedText, 0, ellipsizedText.length());
874 
875         // Chip start padding is the same as the end padding if there is no contact image.
876         final int startPadding = displayIcon ? mChipTextStartPadding : mChipTextEndPadding;
877         // Make sure there is a minimum chip width so the user can ALWAYS
878         // tap a chip without difficulty.
879         int width = Math.max(iconWidth * 2,
880                 textWidth
881                     + startPadding
882                     + mChipTextEndPadding
883                     + iconWidth
884                     + (int) warningIconWidth
885                     + (int) warningIconEndMargin
886                     + backgroundPadding.left
887                     + backgroundPadding.right
888                     + indicatorPadding);
889 
890         // Create the background of the chip.
891         result.bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
892         final Canvas canvas = new Canvas(result.bitmap);
893 
894         // Check if the background drawable is set via attr
895         if (overrideBackgroundDrawable != null) {
896             overrideBackgroundDrawable.setBounds(0, 0, width, height);
897             overrideBackgroundDrawable.draw(canvas);
898         } else {
899             // Draw the default chip background
900             mWorkPaint.reset();
901             mWorkPaint.setColor(backgroundColor);
902             final float radius = height / 2;
903             canvas.drawRoundRect(new RectF(0, 0, width, height), radius, radius,
904                     mWorkPaint);
905         }
906 
907         // Draw the text vertically aligned
908         int textX = shouldPositionAvatarOnRight() ?
909                 mChipTextEndPadding
910                     + backgroundPadding.left
911                     + indicatorPadding
912                     + (int) warningIconWidth
913                     + (int) warningIconEndMargin :
914                 width
915                     - backgroundPadding.right
916                     - mChipTextEndPadding
917                     - textWidth
918                     - indicatorPadding
919                     - (int) warningIconWidth
920                     - (int) warningIconEndMargin;
921         canvas.drawText(ellipsizedText, 0, ellipsizedText.length(),
922                 textX, getTextYOffset(height), paint);
923 
924         if (indicatorIcon != null) {
925             int indicatorX = shouldPositionAvatarOnRight()
926                 ? backgroundPadding.left + mChipTextEndPadding
927                 : width - backgroundPadding.right - indicatorIcon.getBounds().width()
928                         - mChipTextEndPadding;
929             int indicatorY = height / 2 - indicatorIcon.getBounds().height() / 2;
930             indicatorIcon.getBounds().offsetTo(indicatorX, indicatorY);
931             indicatorIcon.draw(canvas);
932         }
933 
934         // Set the variables that are needed to draw the icon bitmap once it's loaded
935         final int iconX = shouldPositionAvatarOnRight() ?
936                 width - backgroundPadding.right - iconWidth :
937                 backgroundPadding.left;
938         result.left = iconX;
939         result.top = backgroundPadding.top;
940         result.right = iconX + iconWidth;
941         result.bottom = height - backgroundPadding.bottom;
942         result.loadIcon = displayIcon;
943 
944         // Set the variables needed to draw the warning icon bitmap once it's loaded.
945         final float warningIconX = shouldPositionAvatarOnRight() ?
946                 backgroundPadding.left + warningIconEndMargin :
947                 width - backgroundPadding.right - warningIconWidth - warningIconEndMargin;
948         final float warningIconY = warningIconTopMargin;
949         result.warningIconLeft = warningIconX;
950         result.warningIconTop = warningIconY;
951         result.warningIconRight = warningIconX + warningIconWidth;
952         result.warningIconBottom = warningIconY + mWarningIconHeight;
953 
954         return result;
955     }
956 
957     /**
958      * Helper function that draws the loaded icon bitmap into the chips bitmap
959      */
drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon)960     private void drawIcon(ChipBitmapContainer bitMapResult, Bitmap icon) {
961         if (icon == null) {
962             return;
963         }
964         final Canvas canvas = new Canvas(bitMapResult.bitmap);
965         final RectF src = new RectF(0, 0, icon.getWidth(), icon.getHeight());
966         final RectF dst = new RectF(bitMapResult.left, bitMapResult.top, bitMapResult.right,
967                 bitMapResult.bottom);
968         drawCircularIconOnCanvas(icon, canvas, src, dst);
969     }
970 
971     /**
972      * Draws the warning icon onto the chip's bitmap and returns the rectangle it drew on.
973      */
drawWarningIcon(ChipBitmapContainer bitMapResult)974     private RectF drawWarningIcon(ChipBitmapContainer bitMapResult) {
975         if (mWarningIcon == null) {
976             return new RectF(0, 0, 0, 0);
977         }
978         final Canvas canvas = new Canvas(bitMapResult.bitmap);
979         final RectF src = new RectF(0, 0, mWarningIcon.getWidth(), mWarningIcon.getHeight());
980         final RectF dst = new RectF(bitMapResult.warningIconLeft, bitMapResult.warningIconTop,
981                 bitMapResult.warningIconRight, bitMapResult.warningIconBottom);
982         drawRectanglularIconOnCanvas(mWarningIcon, canvas, src, dst);
983         return dst;
984     }
985 
986     /**
987      * Returns true if the avatar should be positioned at the right edge of the chip.
988      * Takes into account both the set avatar position (start or end) as well as whether
989      * the layout direction is LTR or RTL.
990      */
shouldPositionAvatarOnRight()991     private boolean shouldPositionAvatarOnRight() {
992         final boolean isRtl = Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1 &&
993                 getLayoutDirection() == LAYOUT_DIRECTION_RTL;
994         final boolean assignedPosition = mAvatarPosition == AVATAR_POSITION_END;
995         // If in Rtl mode, the position should be flipped.
996         return isRtl ? !assignedPosition : assignedPosition;
997     }
998 
999     /**
1000      * Returns the avatar icon to use for this recipient entry. Returns null if we don't want to
1001      * draw an icon for this recipient.
1002      */
loadAvatarIcon(final RecipientEntry contact, final ChipBitmapContainer bitmapContainer)1003     private void loadAvatarIcon(final RecipientEntry contact,
1004             final ChipBitmapContainer bitmapContainer) {
1005         // Don't draw photos for recipients that have been typed in OR generated on the fly.
1006         long contactId = contact.getContactId();
1007         boolean drawPhotos = isPhoneQuery() ?
1008                 contactId != RecipientEntry.INVALID_CONTACT
1009                 : (contactId != RecipientEntry.INVALID_CONTACT
1010                         && contactId != RecipientEntry.GENERATED_CONTACT);
1011 
1012         if (drawPhotos) {
1013             final byte[] origPhotoBytes = contact.getPhotoBytes();
1014             // There may not be a photo yet if anything but the first contact address
1015             // was selected.
1016             if (origPhotoBytes == null) {
1017                 // TODO: cache this in the recipient entry?
1018                 getAdapter().fetchPhoto(contact, new PhotoManager.PhotoManagerCallback() {
1019                     @Override
1020                     public void onPhotoBytesPopulated() {
1021                         // Call through to the async version which will ensure
1022                         // proper threading.
1023                         onPhotoBytesAsynchronouslyPopulated();
1024                     }
1025 
1026                     @Override
1027                     public void onPhotoBytesAsynchronouslyPopulated() {
1028                         final byte[] loadedPhotoBytes = contact.getPhotoBytes();
1029                         final Bitmap icon = BitmapFactory.decodeByteArray(loadedPhotoBytes, 0,
1030                                 loadedPhotoBytes.length);
1031                         tryDrawAndInvalidate(icon);
1032                     }
1033 
1034                     @Override
1035                     public void onPhotoBytesAsyncLoadFailed() {
1036                         // TODO: can the scaled down default photo be cached?
1037                         tryDrawAndInvalidate(mDefaultContactPhoto);
1038                     }
1039 
1040                     private void tryDrawAndInvalidate(Bitmap icon) {
1041                         drawIcon(bitmapContainer, icon);
1042                         // The caller might originated from a background task. However, if the
1043                         // background task has already completed, the view might be already drawn
1044                         // on the UI but the callback would happen on the background thread.
1045                         // So if we are on a background thread, post an invalidate call to the UI.
1046                         if (Looper.myLooper() == Looper.getMainLooper()) {
1047                             // The view might not redraw itself since it's loaded asynchronously
1048                             invalidate();
1049                         } else {
1050                             post(new Runnable() {
1051                                 @Override
1052                                 public void run() {
1053                                     invalidate();
1054                                 }
1055                             });
1056                         }
1057                     }
1058                 });
1059             } else {
1060                 final Bitmap icon = BitmapFactory.decodeByteArray(origPhotoBytes, 0,
1061                         origPhotoBytes.length);
1062                 drawIcon(bitmapContainer, icon);
1063             }
1064         }
1065     }
1066 
1067     /**
1068      * Get the background drawable for a RecipientChip.
1069      */
1070     // Visible for testing.
getChipBackground(RecipientEntry contact)1071     /* package */Drawable getChipBackground(RecipientEntry contact) {
1072         return contact.isValid() ? mChipBackground : mInvalidChipBackground;
1073     }
1074 
getDefaultChipTextColor(RecipientEntry contact)1075     private int getDefaultChipTextColor(RecipientEntry contact) {
1076         return contact.isValid() ? mUnselectedChipTextColor :
1077                 getResources().getColor(android.R.color.black);
1078     }
1079 
getDefaultChipBackgroundColor(RecipientEntry contact)1080     private int getDefaultChipBackgroundColor(RecipientEntry contact) {
1081         return contact.isValid() ? mUnselectedChipBackgroundColor :
1082                 getResources().getColor(R.color.chip_background_invalid);
1083     }
1084 
1085     /**
1086      * Given a height, returns a Y offset that will draw the text in the middle of the height.
1087      */
getTextYOffset(int height)1088     protected float getTextYOffset(int height) {
1089         return height - ((height - mTextHeight) / 2);
1090     }
1091 
1092     /**
1093      * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination
1094      * rectangle of the canvas.
1095      *
1096      * <p>The icon is drawn as a circle.
1097      */
drawCircularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst)1098     protected void drawCircularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) {
1099         setWorkPaintForIcon(icon, src, dst);
1100         canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f, mWorkPaint);
1101 
1102         final float borderWidth = 1f;
1103         setWorkPaintForBorder(borderWidth);
1104         canvas.drawCircle(dst.centerX(), dst.centerY(), dst.width() / 2f - borderWidth / 2,
1105                 mWorkPaint);
1106 
1107         mWorkPaint.reset();
1108     }
1109 
1110     /**
1111      * Draws the icon onto the canvas given the source rectangle of the bitmap and the destination
1112      * rectangle of the canvas.
1113      *
1114      * <p>The icon is drawn as a rectangle.
1115      */
drawRectanglularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst)1116     private void drawRectanglularIconOnCanvas(Bitmap icon, Canvas canvas, RectF src, RectF dst) {
1117         setWorkPaintForIcon(icon, src, dst);
1118         canvas.drawRect(dst, mWorkPaint);
1119 
1120         final float borderWidth = 1f;
1121         setWorkPaintForBorder(borderWidth);
1122         canvas.drawRect(dst, mWorkPaint);
1123 
1124         mWorkPaint.reset();
1125     }
1126 
1127     /**
1128      * Sets WorkPaint for drawing the icon from src onto dst.
1129      */
setWorkPaintForIcon(Bitmap icon, RectF src, RectF dst)1130     private void setWorkPaintForIcon(Bitmap icon, RectF src, RectF dst) {
1131         final Matrix matrix = new Matrix();
1132 
1133         // Draw bitmap through shader first.
1134         final BitmapShader shader = new BitmapShader(icon, TileMode.CLAMP, TileMode.CLAMP);
1135         matrix.reset();
1136 
1137         // Fit bitmap to bounds.
1138         matrix.setRectToRect(src, dst, Matrix.ScaleToFit.FILL);
1139 
1140         shader.setLocalMatrix(matrix);
1141         mWorkPaint.reset();
1142         mWorkPaint.setShader(shader);
1143         mWorkPaint.setAntiAlias(true);
1144         mWorkPaint.setFilterBitmap(true);
1145         mWorkPaint.setDither(true);
1146     }
1147 
1148     /**
1149      * Sets WorkPaint for drawing the icon border with the given width.
1150      */
setWorkPaintForBorder(float borderWidth)1151     private void setWorkPaintForBorder(float borderWidth) {
1152         mWorkPaint.reset();
1153         mWorkPaint.setColor(Color.TRANSPARENT);
1154         mWorkPaint.setStyle(Style.STROKE);
1155         mWorkPaint.setStrokeWidth(borderWidth);
1156         mWorkPaint.setAntiAlias(true);
1157     }
1158 
constructChipSpan(RecipientEntry contact)1159     private DrawableRecipientChip constructChipSpan(RecipientEntry contact) {
1160         TextPaint paint = getPaint();
1161         float defaultSize = paint.getTextSize();
1162         int defaultColor = paint.getColor();
1163 
1164         ChipBitmapContainer bitmapContainer = createChipBitmap(contact, paint);
1165         final Rect warningIconBounds = new Rect(0, 0, 0, 0);
1166         if (mUntrustedAddresses.contains(contact.getDestination())) {
1167             drawWarningIcon(bitmapContainer).round(warningIconBounds);
1168         }
1169         final Bitmap tmpBitmap = bitmapContainer.bitmap;
1170 
1171         // Pass the full text, un-ellipsized, to the chip.
1172         final int iconWidth = tmpBitmap != null ? tmpBitmap.getWidth() : 0;
1173         final int iconHeight = tmpBitmap != null ? tmpBitmap.getHeight() : 0;
1174         Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
1175         result.setBounds(0, 0, iconWidth, iconHeight);
1176         VisibleRecipientChip recipientChip =
1177                 new VisibleRecipientChip(result, contact);
1178         recipientChip.setExtraMargin(mLineSpacingExtra);
1179         // Return text to the original size.
1180         paint.setTextSize(defaultSize);
1181         paint.setColor(defaultColor);
1182         // Put warning icon dimensions info in the chip
1183         recipientChip.setWarningIconBounds(warningIconBounds);
1184         return recipientChip;
1185     }
1186 
1187     /**
1188      * Calculate the offset from bottom of the EditText to top of the provided line.
1189      */
calculateOffsetFromBottomToTop(int line)1190     private int calculateOffsetFromBottomToTop(int line) {
1191         return -(int) ((mChipHeight + (2 * mLineSpacingExtra)) * (Math
1192                 .abs(getLineCount() - line)) + getPaddingBottom());
1193     }
1194 
1195     /**
1196      * Get the max amount of space a chip can take up. The formula takes into
1197      * account the width of the EditTextView, any view padding, and padding
1198      * that will be added to the chip.
1199      */
calculateAvailableWidth()1200     private float calculateAvailableWidth() {
1201         return getWidth() - getPaddingLeft() - getPaddingRight() - mChipTextStartPadding
1202                 - mChipTextEndPadding;
1203     }
1204 
1205 
setChipDimensions(Context context, AttributeSet attrs)1206     private void setChipDimensions(Context context, AttributeSet attrs) {
1207         TypedArray a = context.obtainStyledAttributes(attrs, R.styleable.RecipientEditTextView, 0,
1208                 0);
1209         Resources r = getContext().getResources();
1210 
1211         mChipBackground = a.getDrawable(R.styleable.RecipientEditTextView_chipBackground);
1212         mInvalidChipBackground = a
1213                 .getDrawable(R.styleable.RecipientEditTextView_invalidChipBackground);
1214         mChipDelete = a.getDrawable(R.styleable.RecipientEditTextView_chipDelete);
1215         if (mChipDelete == null) {
1216             mChipDelete = r.getDrawable(R.drawable.ic_cancel_wht_24dp);
1217         }
1218         mChipTextStartPadding = mChipTextEndPadding
1219                 = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipPadding, -1);
1220         if (mChipTextStartPadding == -1) {
1221             mChipTextStartPadding = mChipTextEndPadding =
1222                     (int) r.getDimension(R.dimen.chip_padding);
1223         }
1224         // xml-overrides for each individual padding
1225         // TODO: add these to attr?
1226         int overridePadding = (int) r.getDimension(R.dimen.chip_padding_start);
1227         if (overridePadding >= 0) {
1228             mChipTextStartPadding = overridePadding;
1229         }
1230         overridePadding = (int) r.getDimension(R.dimen.chip_padding_end);
1231         if (overridePadding >= 0) {
1232             mChipTextEndPadding = overridePadding;
1233         }
1234 
1235         mDefaultContactPhoto = BitmapFactory.decodeResource(r, R.drawable.ic_contact_picture);
1236 
1237         mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(R.layout.more_item, null);
1238 
1239         mChipHeight = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipHeight, -1);
1240         if (mChipHeight == -1) {
1241             mChipHeight = r.getDimension(R.dimen.chip_height);
1242         }
1243         mChipFontSize = a.getDimensionPixelSize(R.styleable.RecipientEditTextView_chipFontSize, -1);
1244         if (mChipFontSize == -1) {
1245             mChipFontSize = r.getDimension(R.dimen.chip_text_size);
1246         }
1247         mAvatarPosition =
1248                 a.getInt(R.styleable.RecipientEditTextView_avatarPosition, AVATAR_POSITION_START);
1249         mDisableDelete = a.getBoolean(R.styleable.RecipientEditTextView_disableDelete, false);
1250 
1251         mMaxLines = r.getInteger(R.integer.chips_max_lines);
1252         mLineSpacingExtra = r.getDimensionPixelOffset(R.dimen.line_spacing_extra);
1253 
1254         mUnselectedChipTextColor = a.getColor(
1255                 R.styleable.RecipientEditTextView_unselectedChipTextColor,
1256                 r.getColor(android.R.color.black));
1257 
1258         mUnselectedChipBackgroundColor = a.getColor(
1259                 R.styleable.RecipientEditTextView_unselectedChipBackgroundColor,
1260                 r.getColor(R.color.chip_background));
1261 
1262         a.recycle();
1263     }
1264 
1265     // Visible for testing.
setMoreItem(TextView moreItem)1266     /* package */ void setMoreItem(TextView moreItem) {
1267         mMoreItem = moreItem;
1268     }
1269 
1270 
1271     // Visible for testing.
setChipBackground(Drawable chipBackground)1272     /* package */ void setChipBackground(Drawable chipBackground) {
1273         mChipBackground = chipBackground;
1274     }
1275 
1276     // Visible for testing.
setChipHeight(int height)1277     /* package */ void setChipHeight(int height) {
1278         mChipHeight = height;
1279     }
1280 
getChipHeight()1281     public float getChipHeight() {
1282         return mChipHeight;
1283     }
1284 
1285     /** Returns whether view is in no-chip or chip mode. */
isNoChipMode()1286     public boolean isNoChipMode() {
1287         return mNoChipMode;
1288     }
1289 
1290     /**
1291      * Set whether to shrink the recipients field such that at most
1292      * one line of recipients chips are shown when the field loses
1293      * focus. By default, the number of displayed recipients will be
1294      * limited and a "more" chip will be shown when focus is lost.
1295      * @param shrink
1296      */
setOnFocusListShrinkRecipients(boolean shrink)1297     public void setOnFocusListShrinkRecipients(boolean shrink) {
1298         mShouldShrink = shrink;
1299     }
1300 
1301     @Override
onSizeChanged(int width, int height, int oldw, int oldh)1302     public void onSizeChanged(int width, int height, int oldw, int oldh) {
1303         super.onSizeChanged(width, height, oldw, oldh);
1304         if (width != 0 && height != 0) {
1305             if (mPendingChipsCount > 0) {
1306                 postHandlePendingChips();
1307             } else {
1308                 checkChipWidths();
1309             }
1310         }
1311         // Try to find the scroll view parent, if it exists.
1312         if (mScrollView == null && !mTriedGettingScrollView) {
1313             ViewParent parent = getParent();
1314             while (parent != null && !(parent instanceof ScrollView)) {
1315                 parent = parent.getParent();
1316             }
1317             if (parent != null) {
1318                 mScrollView = (ScrollView) parent;
1319             }
1320             mTriedGettingScrollView = true;
1321         }
1322     }
1323 
postHandlePendingChips()1324     private void postHandlePendingChips() {
1325         mHandler.removeCallbacks(mHandlePendingChips);
1326         mHandler.post(mHandlePendingChips);
1327     }
1328 
checkChipWidths()1329     private void checkChipWidths() {
1330         // Check the widths of the associated chips.
1331         DrawableRecipientChip[] chips = getSortedRecipients();
1332         if (chips != null) {
1333             Rect bounds;
1334             for (DrawableRecipientChip chip : chips) {
1335                 bounds = chip.getBounds();
1336                 if (getWidth() > 0 && bounds.right - bounds.left >
1337                         getWidth() - getPaddingLeft() - getPaddingRight()) {
1338                     // Need to redraw that chip.
1339                     replaceChip(chip, chip.getEntry());
1340                 }
1341             }
1342         }
1343     }
1344 
1345     // Visible for testing.
handlePendingChips()1346     /*package*/ void handlePendingChips() {
1347         if (getViewWidth() <= 0) {
1348             // The widget has not been sized yet.
1349             // This will be called as a result of onSizeChanged
1350             // at a later point.
1351             return;
1352         }
1353         if (mPendingChipsCount <= 0) {
1354             return;
1355         }
1356 
1357         synchronized (mPendingChips) {
1358             Editable editable = getText();
1359             // Tokenize!
1360             if (mPendingChipsCount <= MAX_CHIPS_PARSED) {
1361                 for (int i = 0; i < mPendingChips.size(); i++) {
1362                     String current = mPendingChips.get(i);
1363                     int tokenStart = editable.toString().indexOf(current);
1364                     // Always leave a space at the end between tokens.
1365                     int tokenEnd = tokenStart + current.length() - 1;
1366                     if (tokenStart >= 0) {
1367                         // When we have a valid token, include it with the token
1368                         // to the left.
1369                         if (tokenEnd < editable.length() - 2
1370                                 && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) {
1371                             tokenEnd++;
1372                         }
1373                         createReplacementChip(tokenStart, tokenEnd, editable, i < CHIP_LIMIT
1374                                 || !mShouldShrink);
1375                     }
1376                     mPendingChipsCount--;
1377                 }
1378                 sanitizeEnd();
1379             } else {
1380                 mNoChipMode = true;
1381             }
1382 
1383             if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0
1384                     && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) {
1385                 if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) {
1386                     new RecipientReplacementTask().execute();
1387                     mTemporaryRecipients = null;
1388                 } else {
1389                     // Create the "more" chip
1390                     mIndividualReplacements = new IndividualReplacementTask();
1391                     mIndividualReplacements.execute(new ArrayList<DrawableRecipientChip>(
1392                             mTemporaryRecipients.subList(0, CHIP_LIMIT)));
1393                     if (mTemporaryRecipients.size() > CHIP_LIMIT) {
1394                         mTemporaryRecipients = new ArrayList<DrawableRecipientChip>(
1395                                 mTemporaryRecipients.subList(CHIP_LIMIT,
1396                                         mTemporaryRecipients.size()));
1397                     } else {
1398                         mTemporaryRecipients = null;
1399                     }
1400                     createMoreChip();
1401                 }
1402             } else {
1403                 // There are too many recipients to look up, so just fall back
1404                 // to showing addresses for all of them.
1405                 mTemporaryRecipients = null;
1406                 createMoreChip();
1407             }
1408             mPendingChipsCount = 0;
1409             mPendingChips.clear();
1410         }
1411     }
1412 
1413     // Visible for testing.
getViewWidth()1414     /*package*/ int getViewWidth() {
1415         return getWidth();
1416     }
1417 
1418     /**
1419      * Remove any characters after the last valid chip.
1420      */
1421     // Visible for testing.
sanitizeEnd()1422     /*package*/ void sanitizeEnd() {
1423         // Don't sanitize while we are waiting for pending chips to complete.
1424         if (mPendingChipsCount > 0) {
1425             return;
1426         }
1427         // Find the last chip; eliminate any commit characters after it.
1428         DrawableRecipientChip[] chips = getSortedRecipients();
1429         Spannable spannable = getSpannable();
1430         if (chips != null && chips.length > 0) {
1431             int end;
1432             mMoreChip = getMoreChip();
1433             if (mMoreChip != null) {
1434                 end = spannable.getSpanEnd(mMoreChip);
1435             } else {
1436                 end = getSpannable().getSpanEnd(getLastChip());
1437             }
1438             Editable editable = getText();
1439             int length = editable.length();
1440             if (length > end) {
1441                 // See what characters occur after that and eliminate them.
1442                 if (Log.isLoggable(TAG, Log.DEBUG)) {
1443                     Log.d(TAG, "There were extra characters after the last tokenizable entry."
1444                             + editable);
1445                 }
1446                 editable.delete(end + 1, length);
1447             }
1448         }
1449     }
1450 
1451     /**
1452      * Create a chip that represents just the email address of a recipient. At some later
1453      * point, this chip will be attached to a real contact entry, if one exists.
1454      */
1455     // VisibleForTesting
createReplacementChip(int tokenStart, int tokenEnd, Editable editable, boolean visible)1456     void createReplacementChip(int tokenStart, int tokenEnd, Editable editable,
1457             boolean visible) {
1458         if (alreadyHasChip(tokenStart, tokenEnd)) {
1459             // There is already a chip present at this location.
1460             // Don't recreate it.
1461             return;
1462         }
1463         String token = editable.toString().substring(tokenStart, tokenEnd);
1464         final String trimmedToken = token.trim();
1465         int commitCharIndex = trimmedToken.lastIndexOf(COMMIT_CHAR_COMMA);
1466         if (commitCharIndex != -1 && commitCharIndex == trimmedToken.length() - 1) {
1467             token = trimmedToken.substring(0, trimmedToken.length() - 1);
1468         }
1469         RecipientEntry entry = createTokenizedEntry(token);
1470         if (entry != null) {
1471             DrawableRecipientChip chip = null;
1472             try {
1473                 if (!mNoChipMode) {
1474                     chip = visible ? constructChipSpan(entry) : new InvisibleRecipientChip(entry);
1475                 }
1476             } catch (NullPointerException e) {
1477                 Log.e(TAG, e.getMessage(), e);
1478             }
1479             editable.setSpan(chip, tokenStart, tokenEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1480             // Add this chip to the list of entries "to replace"
1481             if (chip != null) {
1482                 if (mTemporaryRecipients == null) {
1483                     mTemporaryRecipients = new ArrayList<DrawableRecipientChip>();
1484                 }
1485                 chip.setOriginalText(token);
1486                 mTemporaryRecipients.add(chip);
1487             }
1488         }
1489     }
1490 
1491     // VisibleForTesting
createTokenizedEntry(final String token)1492     RecipientEntry createTokenizedEntry(final String token) {
1493         if (TextUtils.isEmpty(token)) {
1494             return null;
1495         }
1496         if (isPhoneQuery() && PhoneUtil.isPhoneNumber(token)) {
1497             return RecipientEntry.constructFakePhoneEntry(token, true);
1498         }
1499         Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token);
1500         boolean isValid = isValid(token);
1501         if (isValid && tokens != null && tokens.length > 0) {
1502             // If we can get a name from tokenizing, then generate an entry from
1503             // this.
1504             String display = tokens[0].getName();
1505             if (!TextUtils.isEmpty(display)) {
1506                 return RecipientEntry.constructGeneratedEntry(display, tokens[0].getAddress(),
1507                         isValid);
1508             } else {
1509                 display = tokens[0].getAddress();
1510                 if (!TextUtils.isEmpty(display)) {
1511                     return RecipientEntry.constructFakeEntry(display, isValid);
1512                 }
1513             }
1514         }
1515         // Unable to validate the token or to create a valid token from it.
1516         // Just create a chip the user can edit.
1517         String validatedToken = null;
1518         if (mValidator != null && !isValid) {
1519             // Try fixing up the entry using the validator.
1520             validatedToken = mValidator.fixText(token).toString();
1521             if (!TextUtils.isEmpty(validatedToken)) {
1522                 if (validatedToken.contains(token)) {
1523                     // protect against the case of a validator with a null
1524                     // domain,
1525                     // which doesn't add a domain to the token
1526                     Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(validatedToken);
1527                     if (tokenized.length > 0) {
1528                         validatedToken = tokenized[0].getAddress();
1529                         isValid = true;
1530                     }
1531                 } else {
1532                     // We ran into a case where the token was invalid and
1533                     // removed
1534                     // by the validator. In this case, just use the original
1535                     // token
1536                     // and let the user sort out the error chip.
1537                     validatedToken = null;
1538                     isValid = false;
1539                 }
1540             }
1541         }
1542         // Otherwise, fallback to just creating an editable email address chip.
1543         return RecipientEntry.constructFakeEntry(
1544                 !TextUtils.isEmpty(validatedToken) ? validatedToken : token, isValid);
1545     }
1546 
isValid(String text)1547     private boolean isValid(String text) {
1548         return mValidator == null ? true : mValidator.isValid(text);
1549     }
1550 
tokenizeAddress(String destination)1551     private static String tokenizeAddress(String destination) {
1552         Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination);
1553         if (tokens != null && tokens.length > 0) {
1554             return tokens[0].getAddress();
1555         }
1556         return destination;
1557     }
1558 
1559     @Override
setTokenizer(Tokenizer tokenizer)1560     public void setTokenizer(Tokenizer tokenizer) {
1561         mTokenizer = tokenizer;
1562         super.setTokenizer(mTokenizer);
1563     }
1564 
1565     @Override
setValidator(Validator validator)1566     public void setValidator(Validator validator) {
1567         mValidator = validator;
1568         super.setValidator(validator);
1569     }
1570 
1571     /**
1572      * We cannot use the default mechanism for replaceText. Instead,
1573      * we override onItemClickListener so we can get all the associated
1574      * contact information including display text, address, and id.
1575      */
1576     @Override
replaceText(CharSequence text)1577     protected void replaceText(CharSequence text) {
1578         return;
1579     }
1580 
1581     /**
1582      * Dismiss any selected chips when the back key is pressed.
1583      */
1584     @Override
onKeyPreIme(int keyCode, @NonNull KeyEvent event)1585     public boolean onKeyPreIme(int keyCode, @NonNull KeyEvent event) {
1586         if (keyCode == KeyEvent.KEYCODE_BACK && mSelectedChip != null) {
1587             clearSelectedChip();
1588             return true;
1589         }
1590         return super.onKeyPreIme(keyCode, event);
1591     }
1592 
1593     /**
1594      * Monitor key presses in this view to see if the user types
1595      * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER.
1596      * If the user has entered text that has contact matches and types
1597      * a commit key, create a chip from the topmost matching contact.
1598      * If the user has entered text that has no contact matches and types
1599      * a commit key, then create a chip from the text they have entered.
1600      */
1601     @Override
onKeyUp(int keyCode, @NonNull KeyEvent event)1602     public boolean onKeyUp(int keyCode, @NonNull KeyEvent event) {
1603         switch (keyCode) {
1604             case KeyEvent.KEYCODE_TAB:
1605                 if (event.hasNoModifiers()) {
1606                     if (mSelectedChip != null) {
1607                         clearSelectedChip();
1608                     } else {
1609                         commitDefault();
1610                     }
1611                 }
1612                 break;
1613         }
1614         return super.onKeyUp(keyCode, event);
1615     }
1616 
focusNext()1617     private boolean focusNext() {
1618         View next = focusSearch(View.FOCUS_DOWN);
1619         if (next != null) {
1620             next.requestFocus();
1621             return true;
1622         }
1623         return false;
1624     }
1625 
1626     /**
1627      * Create a chip from the default selection. If the popup is showing, the
1628      * default is the selected item (if one is selected), or the first item, in the popup
1629      * suggestions list. Otherwise, it is whatever the user had typed in. End represents where the
1630      * tokenizer should search for a token to turn into a chip.
1631      * @return If a chip was created from a real contact.
1632      */
commitDefault()1633     private boolean commitDefault() {
1634         // If there is no tokenizer, don't try to commit.
1635         if (mTokenizer == null) {
1636             return false;
1637         }
1638         Editable editable = getText();
1639         int end = getSelectionEnd();
1640         int start = mTokenizer.findTokenStart(editable, end);
1641 
1642         if (shouldCreateChip(start, end)) {
1643             int whatEnd = mTokenizer.findTokenEnd(getText(), start);
1644             // In the middle of chip; treat this as an edit
1645             // and commit the whole token.
1646             whatEnd = movePastTerminators(whatEnd);
1647             if (whatEnd != getSelectionEnd()) {
1648                 handleEdit(start, whatEnd);
1649                 return true;
1650             }
1651             return commitChip(start, end , editable);
1652         }
1653         return false;
1654     }
1655 
commitByCharacter()1656     private void commitByCharacter() {
1657         // We can't possibly commit by character if we can't tokenize.
1658         if (mTokenizer == null) {
1659             return;
1660         }
1661         Editable editable = getText();
1662         int end = getSelectionEnd();
1663         int start = mTokenizer.findTokenStart(editable, end);
1664         if (shouldCreateChip(start, end)) {
1665             commitChip(start, end, editable);
1666         }
1667         setSelection(getText().length());
1668     }
1669 
commitChip(int start, int end, Editable editable)1670     private boolean commitChip(int start, int end, Editable editable) {
1671         int position = positionOfFirstEntryWithTypePerson();
1672         if (position != -1 && enoughToFilter()
1673                 && end == getSelectionEnd() && !isPhoneQuery()
1674                 && !isValidEmailAddress(editable.toString().substring(start, end).trim())) {
1675             // let's choose the selected or first entry if only the input text is NOT an email
1676             // address so we won't try to replace the user's potentially correct but
1677             // new/unencountered email input
1678             final int selectedPosition = getListSelection();
1679             if (selectedPosition == -1 || !isEntryAtPositionTypePerson(selectedPosition)) {
1680                 // Nothing is selected or selected item is not type person; use the first item
1681                 submitItemAtPosition(position);
1682             } else {
1683                 submitItemAtPosition(selectedPosition);
1684             }
1685             dismissDropDown();
1686             return true;
1687         } else {
1688             int tokenEnd = mTokenizer.findTokenEnd(editable, start);
1689             if (editable.length() > tokenEnd + 1) {
1690                 char charAt = editable.charAt(tokenEnd + 1);
1691                 if (charAt == COMMIT_CHAR_COMMA || charAt == COMMIT_CHAR_SEMICOLON) {
1692                     tokenEnd++;
1693                 }
1694             }
1695             String text = editable.toString().substring(start, tokenEnd).trim();
1696             clearComposingText();
1697             if (text.length() > 0 && !text.equals(" ")) {
1698                 RecipientEntry entry = createTokenizedEntry(text);
1699                 if (entry != null) {
1700                     QwertyKeyListener.markAsReplaced(editable, start, end, "");
1701                     CharSequence chipText = createChip(entry);
1702                     if (chipText != null && start > -1 && end > -1) {
1703                         editable.replace(start, end, chipText);
1704                     }
1705                 }
1706                 // Only dismiss the dropdown if it is related to the text we
1707                 // just committed.
1708                 // For paste, it may not be as there are possibly multiple
1709                 // tokens being added.
1710                 if (end == getSelectionEnd()) {
1711                     dismissDropDown();
1712                 }
1713                 sanitizeBetween();
1714                 return true;
1715             }
1716         }
1717         return false;
1718     }
1719 
positionOfFirstEntryWithTypePerson()1720     private int positionOfFirstEntryWithTypePerson() {
1721         ListAdapter adapter = getAdapter();
1722         int itemCount = adapter != null ? adapter.getCount() : 0;
1723         for (int i = 0; i < itemCount; i++) {
1724             if (isEntryAtPositionTypePerson(i)) {
1725                 return i;
1726             }
1727         }
1728         return -1;
1729     }
1730 
isEntryAtPositionTypePerson(int position)1731     private boolean isEntryAtPositionTypePerson(int position) {
1732         return getAdapter().getItem(position).getEntryType() == RecipientEntry.ENTRY_TYPE_PERSON;
1733     }
1734 
1735     // Visible for testing.
sanitizeBetween()1736     /* package */ void sanitizeBetween() {
1737         // Don't sanitize while we are waiting for content to chipify.
1738         if (mPendingChipsCount > 0) {
1739             return;
1740         }
1741         // Find the last chip.
1742         DrawableRecipientChip[] recips = getSortedRecipients();
1743         if (recips != null && recips.length > 0) {
1744             DrawableRecipientChip last = recips[recips.length - 1];
1745             DrawableRecipientChip beforeLast = null;
1746             if (recips.length > 1) {
1747                 beforeLast = recips[recips.length - 2];
1748             }
1749             int startLooking = 0;
1750             int end = getSpannable().getSpanStart(last);
1751             if (beforeLast != null) {
1752                 startLooking = getSpannable().getSpanEnd(beforeLast);
1753                 Editable text = getText();
1754                 if (startLooking == -1 || startLooking > text.length() - 1) {
1755                     // There is nothing after this chip.
1756                     return;
1757                 }
1758                 if (text.charAt(startLooking) == ' ') {
1759                     startLooking++;
1760                 }
1761             }
1762             if (startLooking >= 0 && end >= 0 && startLooking < end) {
1763                 getText().delete(startLooking, end);
1764             }
1765         }
1766     }
1767 
shouldCreateChip(int start, int end)1768     private boolean shouldCreateChip(int start, int end) {
1769         return !mNoChipMode && hasFocus() && enoughToFilter() && !alreadyHasChip(start, end);
1770     }
1771 
alreadyHasChip(int start, int end)1772     private boolean alreadyHasChip(int start, int end) {
1773         if (mNoChipMode) {
1774             return true;
1775         }
1776         DrawableRecipientChip[] chips =
1777                 getSpannable().getSpans(start, end, DrawableRecipientChip.class);
1778         return chips != null && chips.length > 0;
1779     }
1780 
handleEdit(int start, int end)1781     private void handleEdit(int start, int end) {
1782         if (start == -1 || end == -1) {
1783             // This chip no longer exists in the field.
1784             dismissDropDown();
1785             return;
1786         }
1787         // This is in the middle of a chip, so select out the whole chip
1788         // and commit it.
1789         Editable editable = getText();
1790         setSelection(end);
1791         String text = getText().toString().substring(start, end);
1792         if (!TextUtils.isEmpty(text)) {
1793             RecipientEntry entry = RecipientEntry.constructFakeEntry(text, isValid(text));
1794             QwertyKeyListener.markAsReplaced(editable, start, end, "");
1795             CharSequence chipText = createChip(entry);
1796             int selEnd = getSelectionEnd();
1797             if (chipText != null && start > -1 && selEnd > -1) {
1798                 editable.replace(start, selEnd, chipText);
1799             }
1800         }
1801         dismissDropDown();
1802     }
1803 
1804     /**
1805      * If there is a selected chip, delegate the key events
1806      * to the selected chip.
1807      */
1808     @Override
onKeyDown(int keyCode, @NonNull KeyEvent event)1809     public boolean onKeyDown(int keyCode, @NonNull KeyEvent event) {
1810         if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) {
1811             if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
1812                 mAlternatesPopup.dismiss();
1813             }
1814             removeChip(mSelectedChip);
1815         }
1816 
1817         switch (keyCode) {
1818             case KeyEvent.KEYCODE_ENTER:
1819             case KeyEvent.KEYCODE_DPAD_CENTER:
1820                 if (event.hasNoModifiers()) {
1821                     if (commitDefault()) {
1822                         return true;
1823                     }
1824                     if (mSelectedChip != null) {
1825                         clearSelectedChip();
1826                         return true;
1827                     } else if (focusNext()) {
1828                         return true;
1829                     }
1830                 }
1831                 break;
1832         }
1833 
1834         final DrawableRecipientChip lastRecipientChip = getLastChip();
1835         boolean isHandled = super.onKeyDown(keyCode, event);
1836 
1837         /*
1838          * Hacky way to report a deleted chip:
1839          * In some devices/configurations, {@link KeyEvent#KEYCODE_DEL} character is causing
1840          * onKeyDown() to be called, which in turns handles the chip deletion instead of
1841          * {@link RecipientTextWatcher#onTextChanged}. We want to call
1842          * {@link RecipientChipDeletedListener#onRecipientChipDeleted} callback for these cases.
1843          */
1844         if (keyCode == KeyEvent.KEYCODE_DEL && isHandled && lastRecipientChip != null) {
1845             final RecipientEntry entry = lastRecipientChip.getEntry();
1846             if (!mNoChipMode && mRecipientChipDeletedListener != null && entry != null) {
1847                 mRecipientChipDeletedListener.onRecipientChipDeleted(entry);
1848             }
1849         }
1850 
1851         return isHandled;
1852     }
1853 
1854     // Visible for testing.
getSpannable()1855     /* package */ Spannable getSpannable() {
1856         return getText();
1857     }
1858 
getChipStart(DrawableRecipientChip chip)1859     private int getChipStart(DrawableRecipientChip chip) {
1860         return getSpannable().getSpanStart(chip);
1861     }
1862 
getChipEnd(DrawableRecipientChip chip)1863     private int getChipEnd(DrawableRecipientChip chip) {
1864         return getSpannable().getSpanEnd(chip);
1865     }
1866 
1867     /**
1868      * Instead of filtering on the entire contents of the edit box,
1869      * this subclass method filters on the range from
1870      * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
1871      * if the length of that range meets or exceeds {@link #getThreshold}
1872      * and makes sure that the range is not already a Chip.
1873      */
1874     @Override
performFiltering(@onNull CharSequence text, int keyCode)1875     public void performFiltering(@NonNull CharSequence text, int keyCode) {
1876         boolean isCompletedToken = isCompletedToken(text);
1877         if (enoughToFilter() && !isCompletedToken) {
1878             int end = getSelectionEnd();
1879             int start = mTokenizer.findTokenStart(text, end);
1880             // If this is a RecipientChip, don't filter
1881             // on its contents.
1882             Spannable span = getSpannable();
1883             DrawableRecipientChip[] chips = span.getSpans(start, end, DrawableRecipientChip.class);
1884             if (chips != null && chips.length > 0) {
1885                 dismissDropDown();
1886                 return;
1887             }
1888         } else if (isCompletedToken) {
1889             dismissDropDown();
1890             return;
1891         }
1892         super.performFiltering(text, keyCode);
1893     }
1894 
1895     // Visible for testing.
isCompletedToken(CharSequence text)1896     /*package*/ boolean isCompletedToken(CharSequence text) {
1897         if (TextUtils.isEmpty(text)) {
1898             return false;
1899         }
1900         // Check to see if this is a completed token before filtering.
1901         int end = text.length();
1902         int start = mTokenizer.findTokenStart(text, end);
1903         String token = text.toString().substring(start, end).trim();
1904         if (!TextUtils.isEmpty(token)) {
1905             char atEnd = token.charAt(token.length() - 1);
1906             return atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON;
1907         }
1908         return false;
1909     }
1910 
1911     /**
1912      * Clears the selected chip if there is one (and dismissing any popups related to the selected
1913      * chip in the process).
1914      */
clearSelectedChip()1915     public void clearSelectedChip() {
1916         if (mSelectedChip != null) {
1917             unselectChip(mSelectedChip);
1918             mSelectedChip = null;
1919         }
1920         setCursorVisible(true);
1921         setSelection(getText().length());
1922     }
1923 
1924     /**
1925      * Monitor touch events in the RecipientEditTextView.
1926      * If the view does not have focus, any tap on the view
1927      * will just focus the view. If the view has focus, determine
1928      * if the touch target is a recipient chip. If it is and the chip
1929      * is not selected, select it and clear any other selected chips.
1930      * If it isn't, then select that chip.
1931      */
1932     @Override
onTouchEvent(@onNull MotionEvent event)1933     public boolean onTouchEvent(@NonNull MotionEvent event) {
1934         boolean handled;
1935         int action = event.getAction();
1936         final float x = event.getX();
1937         final float y = event.getY();
1938         final int offset = putOffsetInRange(x, y);
1939         final DrawableRecipientChip currentChip = findChip(offset);
1940         if (action == MotionEvent.ACTION_UP) {
1941             boolean touchedWarningIcon = touchedWarningIcon(x, y, currentChip);
1942             if (touchedWarningIcon) {
1943                 String warningText = String.format(mWarningTextTemplate,
1944                     currentChip.getEntry().getDestination());
1945                 showWarningDialog(warningText);
1946                 return true;
1947             }
1948             if (!isFocused()) {
1949                 // Ignore further chip taps until this view is focused.
1950                 return touchedWarningIcon || super.onTouchEvent(event);
1951             }
1952             handled = super.onTouchEvent(event);
1953             if (mSelectedChip == null) {
1954                 mGestureDetector.onTouchEvent(event);
1955             }
1956             boolean chipWasSelected = false;
1957             if (currentChip != null) {
1958                 if (mSelectedChip != null && mSelectedChip != currentChip) {
1959                     clearSelectedChip();
1960                     selectChip(currentChip);
1961                 } else if (mSelectedChip == null) {
1962                     commitDefault();
1963                     selectChip(currentChip);
1964                 } else {
1965                     onClick(mSelectedChip);
1966                 }
1967                 chipWasSelected = true;
1968                 handled = true;
1969             } else if (mSelectedChip != null && shouldShowEditableText(mSelectedChip)) {
1970                 chipWasSelected = true;
1971             }
1972             if (!chipWasSelected) {
1973                 clearSelectedChip();
1974             }
1975         } else {
1976             boolean touchedWarningIcon = touchedWarningIcon(x, y, currentChip);
1977             if (touchedWarningIcon) {
1978                 return true;
1979             }
1980             handled = super.onTouchEvent(event);
1981             if (!isFocused()) {
1982                 return handled;
1983             }
1984             if (mSelectedChip == null) {
1985                 mGestureDetector.onTouchEvent(event);
1986             }
1987         }
1988         return handled;
1989     }
1990 
touchedWarningIcon(float x, float y, DrawableRecipientChip currentChip)1991     private boolean touchedWarningIcon(float x, float y, DrawableRecipientChip currentChip) {
1992         boolean touchedWarningIcon = false;
1993         if (currentChip != null) {
1994             Rect outOfDomainWarningBounds = currentChip.getWarningIconBounds();
1995             if (outOfDomainWarningBounds != null) {
1996                 int chipLeftOffset = shouldPositionAvatarOnRight()
1997                         ? getChipEnd(currentChip) : getChipStart(currentChip);
1998                 float chipLeftPosition = this.getLayout().getPrimaryHorizontal(chipLeftOffset);
1999                 float chipTopPosition = this.getLayout().getLineTop(
2000                         this.getLayout().getLineForOffset(chipLeftOffset)) + getTotalPaddingTop();
2001                 final RectF touchOutOfDomainWarning = new RectF(
2002                         chipLeftPosition + outOfDomainWarningBounds.left,
2003                         chipTopPosition + outOfDomainWarningBounds.top,
2004                         chipLeftPosition + outOfDomainWarningBounds.right,
2005                         chipTopPosition + outOfDomainWarningBounds.bottom);
2006                 touchedWarningIcon = touchOutOfDomainWarning.contains(x, y);
2007             }
2008         }
2009         return touchedWarningIcon;
2010     }
2011 
showWarningDialog(String warningText)2012     private void showWarningDialog(String warningText) {
2013         mCurrentWarningText = warningText;
2014         new AlertDialog.Builder(RecipientEditTextView.this.getContext())
2015                 .setTitle(mWarningTitle)
2016                 .setOnDismissListener(new DialogInterface.OnDismissListener() {
2017                     @Override
2018                     public void onDismiss(DialogInterface dialog) {
2019                         mCurrentWarningText = "";
2020                     }
2021                 })
2022                 .setMessage(mCurrentWarningText)
2023                 .show();
2024     }
2025 
showAlternates(final DrawableRecipientChip currentChip, final ListPopupWindow alternatesPopup)2026     private void showAlternates(final DrawableRecipientChip currentChip,
2027             final ListPopupWindow alternatesPopup) {
2028         new AsyncTask<Void, Void, ListAdapter>() {
2029             @Override
2030             protected ListAdapter doInBackground(final Void... params) {
2031                 return createAlternatesAdapter(currentChip);
2032             }
2033 
2034             @Override
2035             protected void onPostExecute(final ListAdapter result) {
2036                 if (!mAttachedToWindow) {
2037                     return;
2038                 }
2039                 int line = getLayout().getLineForOffset(getChipStart(currentChip));
2040                 int bottomOffset = calculateOffsetFromBottomToTop(line);
2041 
2042                 // Align the alternates popup with the left side of the View,
2043                 // regardless of the position of the chip tapped.
2044                 alternatesPopup.setAnchorView((mAlternatePopupAnchor != null) ?
2045                         mAlternatePopupAnchor : RecipientEditTextView.this);
2046                 alternatesPopup.setVerticalOffset(bottomOffset);
2047                 alternatesPopup.setAdapter(result);
2048                 alternatesPopup.setOnItemClickListener(mAlternatesListener);
2049                 // Clear the checked item.
2050                 mCheckedItem = -1;
2051                 alternatesPopup.show();
2052                 ListView listView = alternatesPopup.getListView();
2053                 listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
2054                 // Checked item would be -1 if the adapter has not
2055                 // loaded the view that should be checked yet. The
2056                 // variable will be set correctly when onCheckedItemChanged
2057                 // is called in a separate thread.
2058                 if (mCheckedItem != -1) {
2059                     listView.setItemChecked(mCheckedItem, true);
2060                     mCheckedItem = -1;
2061                 }
2062             }
2063         }.execute((Void[]) null);
2064     }
2065 
createAlternatesAdapter(DrawableRecipientChip chip)2066     protected ListAdapter createAlternatesAdapter(DrawableRecipientChip chip) {
2067         return new RecipientAlternatesAdapter(getContext(), chip.getContactId(),
2068                 chip.getDirectoryId(), chip.getLookupKey(), chip.getDataId(),
2069                 getAdapter().getQueryType(), this, mDropdownChipLayouter,
2070                 constructStateListDeleteDrawable(), getAdapter().getPermissionsCheckListener());
2071     }
2072 
createSingleAddressAdapter(DrawableRecipientChip currentChip)2073     private ListAdapter createSingleAddressAdapter(DrawableRecipientChip currentChip) {
2074         return new SingleRecipientArrayAdapter(getContext(), currentChip.getEntry(),
2075                 mDropdownChipLayouter, constructStateListDeleteDrawable());
2076     }
2077 
constructStateListDeleteDrawable()2078     private StateListDrawable constructStateListDeleteDrawable() {
2079         // Construct the StateListDrawable from deleteDrawable
2080         StateListDrawable deleteDrawable = new StateListDrawable();
2081         if (!mDisableDelete) {
2082             deleteDrawable.addState(new int[]{android.R.attr.state_activated}, mChipDelete);
2083         }
2084         deleteDrawable.addState(new int[0], null);
2085         return deleteDrawable;
2086     }
2087 
2088     @Override
onCheckedItemChanged(int position)2089     public void onCheckedItemChanged(int position) {
2090         ListView listView = mAlternatesPopup.getListView();
2091         if (listView != null && listView.getCheckedItemCount() == 0) {
2092             listView.setItemChecked(position, true);
2093         }
2094         mCheckedItem = position;
2095     }
2096 
putOffsetInRange(final float x, final float y)2097     private int putOffsetInRange(final float x, final float y) {
2098         final int offset;
2099 
2100         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
2101             offset = getOffsetForPosition(x, y);
2102         } else {
2103             offset = supportGetOffsetForPosition(x, y);
2104         }
2105 
2106         return putOffsetInRange(offset);
2107     }
2108 
2109     // TODO: This algorithm will need a lot of tweaking after more people have used
2110     // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
2111     // what comes before the finger.
putOffsetInRange(int o)2112     private int putOffsetInRange(int o) {
2113         int offset = o;
2114         Editable text = getText();
2115         int length = text.length();
2116         // Remove whitespace from end to find "real end"
2117         int realLength = length;
2118         for (int i = length - 1; i >= 0; i--) {
2119             if (text.charAt(i) == ' ') {
2120                 realLength--;
2121             } else {
2122                 break;
2123             }
2124         }
2125 
2126         // If the offset is beyond or at the end of the text,
2127         // leave it alone.
2128         if (offset >= realLength) {
2129             return offset;
2130         }
2131         Editable editable = getText();
2132         while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) {
2133             // Keep walking backward!
2134             offset--;
2135         }
2136         return offset;
2137     }
2138 
findText(Editable text, int offset)2139     private static int findText(Editable text, int offset) {
2140         if (text.charAt(offset) != ' ') {
2141             return offset;
2142         }
2143         return -1;
2144     }
2145 
findChip(int offset)2146     private DrawableRecipientChip findChip(int offset) {
2147         final Spannable span = getSpannable();
2148         final DrawableRecipientChip[] chips =
2149                 span.getSpans(0, span.length(), DrawableRecipientChip.class);
2150         // Find the chip that contains this offset.
2151         for (DrawableRecipientChip chip : chips) {
2152             int start = getChipStart(chip);
2153             int end = getChipEnd(chip);
2154             if (offset >= start && offset <= end) {
2155                 return chip;
2156             }
2157         }
2158         return null;
2159     }
2160 
2161     // Visible for testing.
2162     // Use this method to generate text to add to the list of addresses.
createAddressText(RecipientEntry entry)2163     /* package */String createAddressText(RecipientEntry entry) {
2164         String display = entry.getDisplayName();
2165         String address = entry.getDestination();
2166         if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) {
2167             display = null;
2168         }
2169         String trimmedDisplayText;
2170         if (isPhoneQuery() && PhoneUtil.isPhoneNumber(address)) {
2171             trimmedDisplayText = address.trim();
2172         } else {
2173             if (address != null) {
2174                 // Tokenize out the address in case the address already
2175                 // contained the username as well.
2176                 Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(address);
2177                 if (tokenized != null && tokenized.length > 0) {
2178                     address = tokenized[0].getAddress();
2179                 }
2180             }
2181             Rfc822Token token = new Rfc822Token(display, address, null);
2182             trimmedDisplayText = token.toString().trim();
2183         }
2184         int index = trimmedDisplayText.indexOf(",");
2185         return mTokenizer != null && !TextUtils.isEmpty(trimmedDisplayText)
2186                 && index < trimmedDisplayText.length() - 1 ? (String) mTokenizer
2187                 .terminateToken(trimmedDisplayText) : trimmedDisplayText;
2188     }
2189 
2190     // Visible for testing.
2191     // Use this method to generate text to display in a chip.
createChipDisplayText(RecipientEntry entry)2192     /*package*/ String createChipDisplayText(RecipientEntry entry) {
2193         String display = entry.getDisplayName();
2194         String address = entry.getDestination();
2195         if (TextUtils.isEmpty(display) || TextUtils.equals(display, address)) {
2196             display = null;
2197         }
2198         if (!TextUtils.isEmpty(display)) {
2199             return display;
2200         } else if (!TextUtils.isEmpty(address)){
2201             return address;
2202         } else {
2203             return new Rfc822Token(display, address, null).toString();
2204         }
2205     }
2206 
createChip(RecipientEntry entry)2207     private CharSequence createChip(RecipientEntry entry) {
2208         final String displayText = createAddressText(entry);
2209         if (TextUtils.isEmpty(displayText)) {
2210             return null;
2211         }
2212         // Always leave a blank space at the end of a chip.
2213         final int textLength = displayText.length() - 1;
2214         final SpannableString  chipText = new SpannableString(displayText);
2215         if (!mNoChipMode) {
2216             try {
2217                 DrawableRecipientChip chip = constructChipSpan(entry);
2218                 chipText.setSpan(chip, 0, textLength,
2219                         Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2220                 chip.setOriginalText(chipText.toString());
2221             } catch (NullPointerException e) {
2222                 Log.e(TAG, e.getMessage(), e);
2223                 return null;
2224             }
2225         }
2226         onChipCreated(entry);
2227         return chipText;
2228     }
2229 
2230     /**
2231      * A callback for subclasses to use to know when a chip was created with the
2232      * given RecipientEntry.
2233      */
onChipCreated(RecipientEntry entry)2234     protected void onChipCreated(RecipientEntry entry) {
2235         if (!mNoChipMode && mRecipientChipAddedListener != null) {
2236             mRecipientChipAddedListener.onRecipientChipAdded(entry);
2237         }
2238     }
2239 
2240     /**
2241      * When an item in the suggestions list has been clicked, create a chip from the
2242      * contact information of the selected item.
2243      */
2244     @Override
onItemClick(AdapterView<?> parent, View view, int position, long id)2245     public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
2246         if (position < 0) {
2247             return;
2248         }
2249 
2250         final RecipientEntry entry = getAdapter().getItem(position);
2251         if (entry.getEntryType() == RecipientEntry.ENTRY_TYPE_PERMISSION_REQUEST) {
2252             if (mPermissionsRequestItemClickedListener != null) {
2253                 mPermissionsRequestItemClickedListener
2254                         .onPermissionsRequestItemClicked(this, entry.getPermissions());
2255             }
2256             return;
2257         }
2258 
2259         final int charactersTyped = submitItemAtPosition(position);
2260         if (charactersTyped > -1 && mRecipientEntryItemClickedListener != null) {
2261             mRecipientEntryItemClickedListener
2262                     .onRecipientEntryItemClicked(charactersTyped, position);
2263         }
2264     }
2265 
submitItemAtPosition(int position)2266     private int submitItemAtPosition(int position) {
2267         RecipientEntry entry = createValidatedEntry(getAdapter().getItem(position));
2268         if (entry == null) {
2269             return -1;
2270         }
2271         clearComposingText();
2272 
2273         int end = getSelectionEnd();
2274         int start = mTokenizer.findTokenStart(getText(), end);
2275 
2276         Editable editable = getText();
2277         QwertyKeyListener.markAsReplaced(editable, start, end, "");
2278         CharSequence chip = createChip(entry);
2279         if (chip != null && start >= 0 && end >= 0) {
2280             editable.replace(start, end, chip);
2281         }
2282         sanitizeBetween();
2283 
2284         return end - start;
2285     }
2286 
createValidatedEntry(RecipientEntry item)2287     private RecipientEntry createValidatedEntry(RecipientEntry item) {
2288         if (item == null) {
2289             return null;
2290         }
2291         final RecipientEntry entry;
2292         // If the display name and the address are the same, or if this is a
2293         // valid contact, but the destination is invalid, then make this a fake
2294         // recipient that is editable.
2295         String destination = item.getDestination();
2296         if (!isPhoneQuery() && item.getContactId() == RecipientEntry.GENERATED_CONTACT) {
2297             entry = RecipientEntry.constructGeneratedEntry(item.getDisplayName(),
2298                     destination, item.isValid());
2299         } else if (RecipientEntry.isCreatedRecipient(item.getContactId())
2300                 && (TextUtils.isEmpty(item.getDisplayName())
2301                         || TextUtils.equals(item.getDisplayName(), destination)
2302                         || (mValidator != null && !mValidator.isValid(destination)))) {
2303             entry = RecipientEntry.constructFakeEntry(destination, item.isValid());
2304         } else {
2305             entry = item;
2306         }
2307         return entry;
2308     }
2309 
2310     // Visible for testing.
getSortedRecipients()2311     /* package */DrawableRecipientChip[] getSortedRecipients() {
2312         DrawableRecipientChip[] recips = getSpannable()
2313                 .getSpans(0, getText().length(), DrawableRecipientChip.class);
2314         ArrayList<DrawableRecipientChip> recipientsList = new ArrayList<DrawableRecipientChip>(
2315                 Arrays.asList(recips));
2316         final Spannable spannable = getSpannable();
2317         Collections.sort(recipientsList, new Comparator<DrawableRecipientChip>() {
2318 
2319             @Override
2320             public int compare(DrawableRecipientChip first, DrawableRecipientChip second) {
2321                 int firstStart = spannable.getSpanStart(first);
2322                 int secondStart = spannable.getSpanStart(second);
2323                 if (firstStart < secondStart) {
2324                     return -1;
2325                 } else if (firstStart > secondStart) {
2326                     return 1;
2327                 } else {
2328                     return 0;
2329                 }
2330             }
2331         });
2332         return recipientsList.toArray(new DrawableRecipientChip[recipientsList.size()]);
2333     }
2334 
2335     @Override
onActionItemClicked(ActionMode mode, MenuItem item)2336     public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
2337         return false;
2338     }
2339 
2340     @Override
onDestroyActionMode(ActionMode mode)2341     public void onDestroyActionMode(ActionMode mode) {
2342     }
2343 
2344     @Override
onPrepareActionMode(ActionMode mode, Menu menu)2345     public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
2346         return false;
2347     }
2348 
2349     /**
2350      * No chips are selectable.
2351      */
2352     @Override
onCreateActionMode(ActionMode mode, Menu menu)2353     public boolean onCreateActionMode(ActionMode mode, Menu menu) {
2354         return false;
2355     }
2356 
2357     // Visible for testing.
getMoreChip()2358     /* package */ReplacementDrawableSpan getMoreChip() {
2359         MoreImageSpan[] moreSpans = getSpannable().getSpans(0, getText().length(),
2360                 MoreImageSpan.class);
2361         return moreSpans != null && moreSpans.length > 0 ? moreSpans[0] : null;
2362     }
2363 
createMoreSpan(int count)2364     private MoreImageSpan createMoreSpan(int count) {
2365         String moreText = String.format(mMoreItem.getText().toString(), count);
2366         mWorkPaint.set(getPaint());
2367         mWorkPaint.setTextSize(mMoreItem.getTextSize());
2368         mWorkPaint.setColor(mMoreItem.getCurrentTextColor());
2369         final int width = (int) mWorkPaint.measureText(moreText) + mMoreItem.getPaddingLeft()
2370                 + mMoreItem.getPaddingRight();
2371         final int height = (int) mChipHeight;
2372         Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
2373         Canvas canvas = new Canvas(drawable);
2374         int adjustedHeight = height;
2375         Layout layout = getLayout();
2376         if (layout != null) {
2377             adjustedHeight -= layout.getLineDescent(0);
2378         }
2379         canvas.drawText(moreText, 0, moreText.length(), 0, adjustedHeight, mWorkPaint);
2380 
2381         Drawable result = new BitmapDrawable(getResources(), drawable);
2382         result.setBounds(0, 0, width, height);
2383         return new MoreImageSpan(result);
2384     }
2385 
2386     // Visible for testing.
createMoreChipPlainText()2387     /*package*/ void createMoreChipPlainText() {
2388         // Take the first <= CHIP_LIMIT addresses and get to the end of the second one.
2389         Editable text = getText();
2390         int start = 0;
2391         int end = start;
2392         for (int i = 0; i < CHIP_LIMIT; i++) {
2393             end = movePastTerminators(mTokenizer.findTokenEnd(text, start));
2394             start = end; // move to the next token and get its end.
2395         }
2396         // Now, count total addresses.
2397         int tokenCount = countTokens(text);
2398         MoreImageSpan moreSpan = createMoreSpan(tokenCount - CHIP_LIMIT);
2399         SpannableString chipText = new SpannableString(text.subSequence(end, text.length()));
2400         chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2401         text.replace(end, text.length(), chipText);
2402         mMoreChip = moreSpan;
2403     }
2404 
2405     // Visible for testing.
countTokens(Editable text)2406     /* package */int countTokens(Editable text) {
2407         int tokenCount = 0;
2408         int start = 0;
2409         while (start < text.length()) {
2410             start = movePastTerminators(mTokenizer.findTokenEnd(text, start));
2411             tokenCount++;
2412             if (start >= text.length()) {
2413                 break;
2414             }
2415         }
2416         return tokenCount;
2417     }
2418 
2419     /**
2420      * Create the more chip. The more chip is text that replaces any chips that
2421      * do not fit in the pre-defined available space when the
2422      * RecipientEditTextView loses focus.
2423      */
2424     // Visible for testing.
createMoreChip()2425     /* package */ void createMoreChip() {
2426         if (mNoChipMode) {
2427             createMoreChipPlainText();
2428             return;
2429         }
2430 
2431         if (!mShouldShrink) {
2432             return;
2433         }
2434         ReplacementDrawableSpan[] tempMore = getSpannable().getSpans(0, getText().length(),
2435                 MoreImageSpan.class);
2436         if (tempMore.length > 0) {
2437             getSpannable().removeSpan(tempMore[0]);
2438         }
2439         DrawableRecipientChip[] recipients = getSortedRecipients();
2440 
2441         if (recipients == null || recipients.length <= CHIP_LIMIT) {
2442             mMoreChip = null;
2443             return;
2444         }
2445         Spannable spannable = getSpannable();
2446         int numRecipients = recipients.length;
2447         int overage = numRecipients - CHIP_LIMIT;
2448         MoreImageSpan moreSpan = createMoreSpan(overage);
2449         mHiddenSpans = new ArrayList<DrawableRecipientChip>();
2450         int totalReplaceStart = 0;
2451         int totalReplaceEnd = 0;
2452         Editable text = getText();
2453         for (int i = numRecipients - overage; i < recipients.length; i++) {
2454             mHiddenSpans.add(recipients[i]);
2455             if (i == numRecipients - overage) {
2456                 totalReplaceStart = spannable.getSpanStart(recipients[i]);
2457             }
2458             if (i == recipients.length - 1) {
2459                 totalReplaceEnd = spannable.getSpanEnd(recipients[i]);
2460             }
2461             if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) {
2462                 int spanStart = spannable.getSpanStart(recipients[i]);
2463                 int spanEnd = spannable.getSpanEnd(recipients[i]);
2464                 recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd));
2465             }
2466             spannable.removeSpan(recipients[i]);
2467         }
2468         if (totalReplaceEnd < text.length()) {
2469             totalReplaceEnd = text.length();
2470         }
2471         int end = Math.max(totalReplaceStart, totalReplaceEnd);
2472         int start = Math.min(totalReplaceStart, totalReplaceEnd);
2473         SpannableString chipText = new SpannableString(text.subSequence(start, end));
2474         chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2475         text.replace(start, end, chipText);
2476         mMoreChip = moreSpan;
2477         // If adding the +more chip goes over the limit, resize accordingly.
2478         if (!isPhoneQuery() && getLineCount() > mMaxLines) {
2479             setMaxLines(getLineCount());
2480         }
2481     }
2482 
2483     /**
2484      * Replace the more chip, if it exists, with all of the recipient chips it had
2485      * replaced when the RecipientEditTextView gains focus.
2486      */
2487     // Visible for testing.
removeMoreChip()2488     /*package*/ void removeMoreChip() {
2489         if (mMoreChip != null) {
2490             Spannable span = getSpannable();
2491             span.removeSpan(mMoreChip);
2492             mMoreChip = null;
2493             // Re-add the spans that were hidden.
2494             if (mHiddenSpans != null && mHiddenSpans.size() > 0) {
2495                 // Recreate each hidden span.
2496                 DrawableRecipientChip[] recipients = getSortedRecipients();
2497                 // Start the search for tokens after the last currently visible
2498                 // chip.
2499                 if (recipients == null || recipients.length == 0) {
2500                     return;
2501                 }
2502                 int end = span.getSpanEnd(recipients[recipients.length - 1]);
2503                 Editable editable = getText();
2504                 for (DrawableRecipientChip chip : mHiddenSpans) {
2505                     int chipStart;
2506                     int chipEnd;
2507                     String token;
2508                     // Need to find the location of the chip, again.
2509                     token = (String) chip.getOriginalText();
2510                     // As we find the matching recipient for the hidden spans,
2511                     // reduce the size of the string we need to search.
2512                     // That way, if there are duplicates, we always find the correct
2513                     // recipient.
2514                     chipStart = editable.toString().indexOf(token, end);
2515                     end = chipEnd = Math.min(editable.length(), chipStart + token.length());
2516                     // Only set the span if we found a matching token.
2517                     if (chipStart != -1) {
2518                         editable.setSpan(chip, chipStart, chipEnd,
2519                                 Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
2520                     }
2521                 }
2522                 mHiddenSpans.clear();
2523             }
2524         }
2525     }
2526 
2527     /**
2528      * Show specified chip as selected. If the RecipientChip is just an email address,
2529      * selecting the chip will take the contents of the chip and place it at
2530      * the end of the RecipientEditTextView for inline editing. If the
2531      * RecipientChip is a complete contact, then selecting the chip
2532      * will show a popup window with the address in use highlighted and any other
2533      * alternate addresses for the contact.
2534      * @param currentChip Chip to select.
2535      */
selectChip(DrawableRecipientChip currentChip)2536     private void selectChip(DrawableRecipientChip currentChip) {
2537         if (shouldShowEditableText(currentChip)) {
2538             CharSequence text = currentChip.getValue();
2539             Editable editable = getText();
2540             Spannable spannable = getSpannable();
2541             int spanStart = spannable.getSpanStart(currentChip);
2542             int spanEnd = spannable.getSpanEnd(currentChip);
2543             spannable.removeSpan(currentChip);
2544             // Don't need leading space if it's the only chip
2545             if (spanEnd - spanStart == editable.length() - 1) {
2546                 spanEnd++;
2547             }
2548             editable.delete(spanStart, spanEnd);
2549             setCursorVisible(true);
2550             setSelection(editable.length());
2551             editable.append(text);
2552             mSelectedChip = constructChipSpan(
2553                     RecipientEntry.constructFakeEntry((String) text, isValid(text.toString())));
2554 
2555             /*
2556              * Because chip is destroyed and converted into an editable text, we call
2557              * {@link RecipientChipDeletedListener#onRecipientChipDeleted}. For the cases where
2558              * editable text is not shown (i.e. chip is in user's contact list), chip is focused
2559              * and below callback is not called.
2560              */
2561             if (!mNoChipMode && mRecipientChipDeletedListener != null) {
2562                 mRecipientChipDeletedListener.onRecipientChipDeleted(currentChip.getEntry());
2563             }
2564         } else {
2565             final boolean showAddress =
2566                     currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT ||
2567                     getAdapter().forceShowAddress();
2568             if (showAddress && mNoChipMode) {
2569                 return;
2570             }
2571 
2572             if (isTouchExplorationEnabled()) {
2573                 // The chips cannot be touch-explored. However, doing a double-tap results in
2574                 // the popup being shown for the last chip, which is of no value.
2575                 return;
2576             }
2577 
2578             mSelectedChip = currentChip;
2579             setSelection(getText().getSpanEnd(mSelectedChip));
2580             setCursorVisible(false);
2581 
2582             if (showAddress) {
2583                 showAddress(currentChip, mAddressPopup);
2584             } else {
2585                 showAlternates(currentChip, mAlternatesPopup);
2586             }
2587         }
2588     }
2589 
isTouchExplorationEnabled()2590     private boolean isTouchExplorationEnabled() {
2591         if (Build.VERSION.SDK_INT < Build.VERSION_CODES.ICE_CREAM_SANDWICH) {
2592             return false;
2593         }
2594 
2595         final AccessibilityManager accessibilityManager = (AccessibilityManager)
2596                 getContext().getSystemService(Context.ACCESSIBILITY_SERVICE);
2597         return accessibilityManager.isTouchExplorationEnabled();
2598     }
2599 
shouldShowEditableText(DrawableRecipientChip currentChip)2600     private boolean shouldShowEditableText(DrawableRecipientChip currentChip) {
2601         long contactId = currentChip.getContactId();
2602         return contactId == RecipientEntry.INVALID_CONTACT
2603                 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT);
2604     }
2605 
showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup)2606     private void showAddress(final DrawableRecipientChip currentChip, final ListPopupWindow popup) {
2607         if (!mAttachedToWindow) {
2608             return;
2609         }
2610         int line = getLayout().getLineForOffset(getChipStart(currentChip));
2611         int bottomOffset = calculateOffsetFromBottomToTop(line);
2612         // Align the alternates popup with the left side of the View,
2613         // regardless of the position of the chip tapped.
2614         popup.setAnchorView((mAlternatePopupAnchor != null) ? mAlternatePopupAnchor : this);
2615         popup.setVerticalOffset(bottomOffset);
2616         popup.setAdapter(createSingleAddressAdapter(currentChip));
2617         popup.setOnItemClickListener(new OnItemClickListener() {
2618             @Override
2619             public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
2620                 unselectChip(currentChip);
2621                 popup.dismiss();
2622             }
2623         });
2624         popup.show();
2625         ListView listView = popup.getListView();
2626         listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
2627         listView.setItemChecked(0, true);
2628     }
2629 
2630     /**
2631      * Remove selection from this chip. Unselecting a RecipientChip will render
2632      * the chip without a delete icon and with an unfocused background. This is
2633      * called when the RecipientChip no longer has focus.
2634      */
unselectChip(DrawableRecipientChip chip)2635     private void unselectChip(DrawableRecipientChip chip) {
2636         int start = getChipStart(chip);
2637         int end = getChipEnd(chip);
2638         Editable editable = getText();
2639         mSelectedChip = null;
2640         if (start == -1 || end == -1) {
2641             Log.w(TAG, "The chip doesn't exist or may be a chip a user was editing");
2642             setSelection(editable.length());
2643             commitDefault();
2644         } else {
2645             getSpannable().removeSpan(chip);
2646             QwertyKeyListener.markAsReplaced(editable, start, end, "");
2647             editable.removeSpan(chip);
2648             try {
2649                 if (!mNoChipMode) {
2650                     editable.setSpan(constructChipSpan(chip.getEntry()),
2651                             start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
2652                 }
2653             } catch (NullPointerException e) {
2654                 Log.e(TAG, e.getMessage(), e);
2655             }
2656         }
2657         setCursorVisible(true);
2658         setSelection(editable.length());
2659         if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
2660             mAlternatesPopup.dismiss();
2661         }
2662     }
2663 
2664     @Override
onChipDelete()2665     public void onChipDelete() {
2666         if (mSelectedChip != null) {
2667             if (!mNoChipMode && mRecipientChipDeletedListener != null) {
2668                 mRecipientChipDeletedListener.onRecipientChipDeleted(mSelectedChip.getEntry());
2669             }
2670             removeChip(mSelectedChip);
2671         }
2672         dismissPopups();
2673     }
2674 
2675     @Override
onPermissionRequestDismissed()2676     public void onPermissionRequestDismissed() {
2677         if (mPermissionsRequestItemClickedListener != null) {
2678             mPermissionsRequestItemClickedListener.onPermissionRequestDismissed();
2679         }
2680         dismissDropDown();
2681     }
2682 
dismissPopups()2683     private void dismissPopups() {
2684         if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
2685             mAlternatesPopup.dismiss();
2686         }
2687         if (mAddressPopup != null && mAddressPopup.isShowing()) {
2688             mAddressPopup.dismiss();
2689         }
2690         setSelection(getText().length());
2691     }
2692 
2693     /**
2694      * Remove the chip and any text associated with it from the RecipientEditTextView.
2695      */
2696     // Visible for testing.
removeChip(DrawableRecipientChip chip)2697     /* package */void removeChip(DrawableRecipientChip chip) {
2698         Spannable spannable = getSpannable();
2699         int spanStart = spannable.getSpanStart(chip);
2700         int spanEnd = spannable.getSpanEnd(chip);
2701         Editable text = getText();
2702         int toDelete = spanEnd;
2703         boolean wasSelected = chip == mSelectedChip;
2704         // Clear that there is a selected chip before updating any text.
2705         if (wasSelected) {
2706             mSelectedChip = null;
2707         }
2708         // Always remove trailing spaces when removing a chip.
2709         while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') {
2710             toDelete++;
2711         }
2712         spannable.removeSpan(chip);
2713         if (spanStart >= 0 && toDelete > 0) {
2714             text.delete(spanStart, toDelete);
2715         }
2716         if (wasSelected) {
2717             clearSelectedChip();
2718         }
2719     }
2720 
2721     /**
2722      * Replace this currently selected chip with a new chip
2723      * that uses the contact data provided.
2724      */
2725     // Visible for testing.
replaceChip(DrawableRecipientChip chip, RecipientEntry entry)2726     /*package*/ void replaceChip(DrawableRecipientChip chip, RecipientEntry entry) {
2727         boolean wasSelected = chip == mSelectedChip;
2728         if (wasSelected) {
2729             mSelectedChip = null;
2730         }
2731         int start = getChipStart(chip);
2732         int end = getChipEnd(chip);
2733         getSpannable().removeSpan(chip);
2734         Editable editable = getText();
2735         entry.setInReplacedChip(true);
2736         CharSequence chipText = createChip(entry);
2737         if (chipText != null) {
2738             if (start == -1 || end == -1) {
2739                 Log.e(TAG, "The chip to replace does not exist but should.");
2740                 editable.insert(0, chipText);
2741             } else {
2742                 if (!TextUtils.isEmpty(chipText)) {
2743                     // There may be a space to replace with this chip's new
2744                     // associated space. Check for it
2745                     int toReplace = end;
2746                     while (toReplace >= 0 && toReplace < editable.length()
2747                             && editable.charAt(toReplace) == ' ') {
2748                         toReplace++;
2749                     }
2750                     editable.replace(start, toReplace, chipText);
2751                 }
2752             }
2753         }
2754         setCursorVisible(true);
2755         if (wasSelected) {
2756             clearSelectedChip();
2757         }
2758     }
2759 
2760     /**
2761      * Handle click events for a chip. When a selected chip receives a click
2762      * event, see if that event was in the delete icon. If so, delete it.
2763      * Otherwise, unselect the chip.
2764      */
onClick(DrawableRecipientChip chip)2765     public void onClick(DrawableRecipientChip chip) {
2766         if (chip.isSelected()) {
2767             clearSelectedChip();
2768         }
2769     }
2770 
chipsPending()2771     private boolean chipsPending() {
2772         return mPendingChipsCount > 0 || (mHiddenSpans != null && mHiddenSpans.size() > 0);
2773     }
2774 
2775     @Override
removeTextChangedListener(TextWatcher watcher)2776     public void removeTextChangedListener(TextWatcher watcher) {
2777         mTextWatcher = null;
2778         super.removeTextChangedListener(watcher);
2779     }
2780 
isValidEmailAddress(String input)2781     private boolean isValidEmailAddress(String input) {
2782         return !TextUtils.isEmpty(input) && mValidator != null &&
2783                 mValidator.isValid(input);
2784     }
2785 
2786     private class RecipientTextWatcher implements TextWatcher {
2787 
2788         @Override
afterTextChanged(Editable s)2789         public void afterTextChanged(Editable s) {
2790             // If the text has been set to null or empty, make sure we remove
2791             // all the spans we applied.
2792             if (TextUtils.isEmpty(s)) {
2793                 // Remove all the chips spans.
2794                 Spannable spannable = getSpannable();
2795                 DrawableRecipientChip[] chips = spannable.getSpans(0, getText().length(),
2796                         DrawableRecipientChip.class);
2797                 for (DrawableRecipientChip chip : chips) {
2798                     spannable.removeSpan(chip);
2799                 }
2800                 if (mMoreChip != null) {
2801                     spannable.removeSpan(mMoreChip);
2802                 }
2803                 clearSelectedChip();
2804                 return;
2805             }
2806             // Get whether there are any recipients pending addition to the
2807             // view. If there are, don't do anything in the text watcher.
2808             if (chipsPending()) {
2809                 return;
2810             }
2811             // If the user is editing a chip, don't clear it.
2812             if (mSelectedChip != null) {
2813                 if (!isGeneratedContact(mSelectedChip)) {
2814                     setCursorVisible(true);
2815                     setSelection(getText().length());
2816                     clearSelectedChip();
2817                 } else {
2818                     return;
2819                 }
2820             }
2821             int length = s.length();
2822             // Make sure there is content there to parse and that it is
2823             // not just the commit character.
2824             if (length > 1) {
2825                 if (lastCharacterIsCommitCharacter(s)) {
2826                     commitByCharacter();
2827                     return;
2828                 }
2829                 char last;
2830                 int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1;
2831                 int len = length() - 1;
2832                 if (end != len) {
2833                     last = s.charAt(end);
2834                 } else {
2835                     last = s.charAt(len);
2836                 }
2837                 if (last == COMMIT_CHAR_SPACE) {
2838                     if (!isPhoneQuery()) {
2839                         // Check if this is a valid email address. If it is,
2840                         // commit it.
2841                         String text = getText().toString();
2842                         int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd());
2843                         String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text,
2844                                 tokenStart));
2845                         if (isValidEmailAddress(sub)) {
2846                             commitByCharacter();
2847                         }
2848                     }
2849                 }
2850             }
2851         }
2852 
2853         @Override
onTextChanged(CharSequence s, int start, int before, int count)2854         public void onTextChanged(CharSequence s, int start, int before, int count) {
2855             // The user deleted some text OR some text was replaced; check to
2856             // see if the insertion point is on a space
2857             // following a chip.
2858             if (before - count == 1) {
2859                 // If the item deleted is a space, and the thing before the
2860                 // space is a chip, delete the entire span.
2861                 int selStart = getSelectionStart();
2862                 DrawableRecipientChip[] repl = getSpannable().getSpans(selStart, selStart,
2863                         DrawableRecipientChip.class);
2864                 if (repl.length > 0) {
2865                     // There is a chip there! Just remove it.
2866                     DrawableRecipientChip toDelete = repl[0];
2867                     Editable editable = getText();
2868                     // Add the separator token.
2869                     int deleteStart = editable.getSpanStart(toDelete);
2870                     int deleteEnd = editable.getSpanEnd(toDelete) + 1;
2871                     if (deleteEnd > editable.length()) {
2872                         deleteEnd = editable.length();
2873                     }
2874                     if (!mNoChipMode && mRecipientChipDeletedListener != null) {
2875                         mRecipientChipDeletedListener.onRecipientChipDeleted(toDelete.getEntry());
2876                     }
2877                     editable.removeSpan(toDelete);
2878                     editable.delete(deleteStart, deleteEnd);
2879                 }
2880             } else if (count > before) {
2881                 if (mSelectedChip != null
2882                     && isGeneratedContact(mSelectedChip)) {
2883                     if (lastCharacterIsCommitCharacter(s)) {
2884                         commitByCharacter();
2885                         return;
2886                     }
2887                 }
2888             }
2889         }
2890 
2891         @Override
beforeTextChanged(CharSequence s, int start, int count, int after)2892         public void beforeTextChanged(CharSequence s, int start, int count, int after) {
2893             // Do nothing.
2894         }
2895     }
2896 
lastCharacterIsCommitCharacter(CharSequence s)2897     public boolean lastCharacterIsCommitCharacter(CharSequence s) {
2898         char last;
2899         int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1;
2900         int len = length() - 1;
2901         if (end != len) {
2902             last = s.charAt(end);
2903         } else {
2904             last = s.charAt(len);
2905         }
2906         return last == COMMIT_CHAR_COMMA || last == COMMIT_CHAR_SEMICOLON;
2907     }
2908 
isGeneratedContact(DrawableRecipientChip chip)2909     public boolean isGeneratedContact(DrawableRecipientChip chip) {
2910         long contactId = chip.getContactId();
2911         return contactId == RecipientEntry.INVALID_CONTACT
2912                 || (!isPhoneQuery() && contactId == RecipientEntry.GENERATED_CONTACT);
2913     }
2914 
2915     /**
2916      * Handles pasting a {@link ClipData} to this {@link RecipientEditTextView}.
2917      */
2918     // Visible for testing.
handlePasteClip(ClipData clip)2919     void handlePasteClip(ClipData clip) {
2920         if (clip == null) {
2921             // Do nothing.
2922             return;
2923         }
2924 
2925         final ClipDescription clipDesc = clip.getDescription();
2926         boolean containsSupportedType = clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN)
2927                 || clipDesc.hasMimeType(ClipDescription.MIMETYPE_TEXT_HTML);
2928         if (!containsSupportedType) {
2929             return;
2930         }
2931 
2932         removeTextChangedListener(mTextWatcher);
2933 
2934         final ClipDescription clipDescription = clip.getDescription();
2935         for (int i = 0; i < clip.getItemCount(); i++) {
2936             final String mimeType = clipDescription.getMimeType(i);
2937             final boolean supportedType = ClipDescription.MIMETYPE_TEXT_PLAIN.equals(mimeType)
2938                     || ClipDescription.MIMETYPE_TEXT_HTML.equals(mimeType);
2939             if (!supportedType) {
2940                 // Only plain text and html can be pasted.
2941                 continue;
2942             }
2943 
2944             final CharSequence pastedItem = clip.getItemAt(i).getText();
2945             if (!TextUtils.isEmpty(pastedItem)) {
2946                 final Editable editable = getText();
2947                 final int start = getSelectionStart();
2948                 final int end = getSelectionEnd();
2949                 if (start < 0 || end < 1) {
2950                     // No selection.
2951                     editable.append(pastedItem);
2952                 } else if (start == end) {
2953                     // Insert at position.
2954                     editable.insert(start, pastedItem);
2955                 } else {
2956                     editable.append(pastedItem, start, end);
2957                 }
2958                 handlePasteAndReplace();
2959             }
2960         }
2961 
2962         mHandler.post(mAddTextWatcher);
2963     }
2964 
2965     @Override
onTextContextMenuItem(int id)2966     public boolean onTextContextMenuItem(int id) {
2967         if (id == android.R.id.paste) {
2968             ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(
2969                     Context.CLIPBOARD_SERVICE);
2970             handlePasteClip(clipboard.getPrimaryClip());
2971             return true;
2972         }
2973         return super.onTextContextMenuItem(id);
2974     }
2975 
handlePasteAndReplace()2976     private void handlePasteAndReplace() {
2977         ArrayList<DrawableRecipientChip> created = handlePaste();
2978         if (created != null && created.size() > 0) {
2979             // Perform reverse lookups on the pasted contacts.
2980             IndividualReplacementTask replace = new IndividualReplacementTask();
2981             replace.execute(created);
2982         }
2983     }
2984 
2985     // Visible for testing.
handlePaste()2986     /* package */ArrayList<DrawableRecipientChip> handlePaste() {
2987         String text = getText().toString();
2988         int originalTokenStart = mTokenizer.findTokenStart(text, getSelectionEnd());
2989         String lastAddress = text.substring(originalTokenStart);
2990         int tokenStart = originalTokenStart;
2991         int prevTokenStart = 0;
2992         DrawableRecipientChip findChip = null;
2993         ArrayList<DrawableRecipientChip> created = new ArrayList<DrawableRecipientChip>();
2994         if (tokenStart != 0) {
2995             // There are things before this!
2996             while (tokenStart != 0 && findChip == null && tokenStart != prevTokenStart) {
2997                 prevTokenStart = tokenStart;
2998                 tokenStart = mTokenizer.findTokenStart(text, tokenStart);
2999                 findChip = findChip(tokenStart);
3000                 if (tokenStart == originalTokenStart && findChip == null) {
3001                     break;
3002                 }
3003             }
3004             if (tokenStart != originalTokenStart) {
3005                 if (findChip != null) {
3006                     tokenStart = prevTokenStart;
3007                 }
3008                 int tokenEnd;
3009                 DrawableRecipientChip createdChip;
3010                 while (tokenStart < originalTokenStart) {
3011                     tokenEnd = movePastTerminators(mTokenizer.findTokenEnd(getText().toString(),
3012                             tokenStart));
3013                     commitChip(tokenStart, tokenEnd, getText());
3014                     createdChip = findChip(tokenStart);
3015                     if (createdChip == null) {
3016                         break;
3017                     }
3018                     // +1 for the space at the end.
3019                     tokenStart = getSpannable().getSpanEnd(createdChip) + 1;
3020                     created.add(createdChip);
3021                 }
3022             }
3023         }
3024         // Take a look at the last token. If the token has been completed with a
3025         // commit character, create a chip.
3026         if (isCompletedToken(lastAddress)) {
3027             Editable editable = getText();
3028             tokenStart = editable.toString().indexOf(lastAddress, originalTokenStart);
3029             commitChip(tokenStart, editable.length(), editable);
3030             created.add(findChip(tokenStart));
3031         }
3032         return created;
3033     }
3034 
3035     // Visible for testing.
movePastTerminators(int tokenEnd)3036     /* package */int movePastTerminators(int tokenEnd) {
3037         if (tokenEnd >= length()) {
3038             return tokenEnd;
3039         }
3040         char atEnd = getText().toString().charAt(tokenEnd);
3041         if (atEnd == COMMIT_CHAR_COMMA || atEnd == COMMIT_CHAR_SEMICOLON) {
3042             tokenEnd++;
3043         }
3044         // This token had not only an end token character, but also a space
3045         // separating it from the next token.
3046         if (tokenEnd < length() && getText().toString().charAt(tokenEnd) == ' ') {
3047             tokenEnd++;
3048         }
3049         return tokenEnd;
3050     }
3051 
3052     private class RecipientReplacementTask extends AsyncTask<Void, Void, Void> {
createFreeChip(RecipientEntry entry)3053         private DrawableRecipientChip createFreeChip(RecipientEntry entry) {
3054             try {
3055                 if (mNoChipMode) {
3056                     return null;
3057                 }
3058                 return constructChipSpan(entry);
3059             } catch (NullPointerException e) {
3060                 Log.e(TAG, e.getMessage(), e);
3061                 return null;
3062             }
3063         }
3064 
3065         @Override
onPreExecute()3066         protected void onPreExecute() {
3067             // Ensure everything is in chip-form already, so we don't have text that slowly gets
3068             // replaced
3069             final List<DrawableRecipientChip> originalRecipients =
3070                     new ArrayList<DrawableRecipientChip>();
3071             final DrawableRecipientChip[] existingChips = getSortedRecipients();
3072             Collections.addAll(originalRecipients, existingChips);
3073             if (mHiddenSpans != null) {
3074                 originalRecipients.addAll(mHiddenSpans);
3075             }
3076 
3077             final List<DrawableRecipientChip> replacements =
3078                     new ArrayList<DrawableRecipientChip>(originalRecipients.size());
3079 
3080             for (final DrawableRecipientChip chip : originalRecipients) {
3081                 if (RecipientEntry.isCreatedRecipient(chip.getEntry().getContactId())
3082                         && getSpannable().getSpanStart(chip) != -1) {
3083                     replacements.add(createFreeChip(chip.getEntry()));
3084                 } else {
3085                     replacements.add(null);
3086                 }
3087             }
3088 
3089             processReplacements(originalRecipients, replacements);
3090         }
3091 
3092         @Override
doInBackground(Void... params)3093         protected Void doInBackground(Void... params) {
3094             if (mIndividualReplacements != null) {
3095                 mIndividualReplacements.cancel(true);
3096             }
3097             // For each chip in the list, look up the matching contact.
3098             // If there is a match, replace that chip with the matching
3099             // chip.
3100             final ArrayList<DrawableRecipientChip> recipients =
3101                     new ArrayList<DrawableRecipientChip>();
3102             DrawableRecipientChip[] existingChips = getSortedRecipients();
3103             Collections.addAll(recipients, existingChips);
3104             if (mHiddenSpans != null) {
3105                 recipients.addAll(mHiddenSpans);
3106             }
3107             ArrayList<String> addresses = new ArrayList<String>();
3108             for (DrawableRecipientChip chip : recipients) {
3109                 if (chip != null) {
3110                     addresses.add(createAddressText(chip.getEntry()));
3111                 }
3112             }
3113             final BaseRecipientAdapter adapter = getAdapter();
3114             adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() {
3115                         @Override
3116                         public void matchesFound(Map<String, RecipientEntry> entries) {
3117                             final ArrayList<DrawableRecipientChip> replacements =
3118                                     new ArrayList<DrawableRecipientChip>();
3119                             for (final DrawableRecipientChip temp : recipients) {
3120                                 RecipientEntry entry = null;
3121                                 if (temp != null && RecipientEntry.isCreatedRecipient(
3122                                         temp.getEntry().getContactId())
3123                                         && getSpannable().getSpanStart(temp) != -1) {
3124                                     // Replace this.
3125                                     entry = createValidatedEntry(
3126                                             entries.get(tokenizeAddress(temp.getEntry()
3127                                                     .getDestination())));
3128                                 }
3129                                 if (entry != null) {
3130                                     replacements.add(createFreeChip(entry));
3131                                 } else {
3132                                     replacements.add(null);
3133                                 }
3134                             }
3135                             processReplacements(recipients, replacements);
3136                         }
3137 
3138                         @Override
3139                         public void matchesNotFound(final Set<String> unfoundAddresses) {
3140                             final List<DrawableRecipientChip> replacements =
3141                                     new ArrayList<DrawableRecipientChip>(unfoundAddresses.size());
3142 
3143                             for (final DrawableRecipientChip temp : recipients) {
3144                                 if (temp != null && RecipientEntry.isCreatedRecipient(
3145                                         temp.getEntry().getContactId())
3146                                         && getSpannable().getSpanStart(temp) != -1) {
3147                                     if (unfoundAddresses.contains(
3148                                             temp.getEntry().getDestination())) {
3149                                         replacements.add(createFreeChip(temp.getEntry()));
3150                                     } else {
3151                                         replacements.add(null);
3152                                     }
3153                                 } else {
3154                                     replacements.add(null);
3155                                 }
3156                             }
3157 
3158                             processReplacements(recipients, replacements);
3159                         }
3160                     });
3161             return null;
3162         }
3163 
processReplacements(final List<DrawableRecipientChip> recipients, final List<DrawableRecipientChip> replacements)3164         private void processReplacements(final List<DrawableRecipientChip> recipients,
3165                 final List<DrawableRecipientChip> replacements) {
3166             if (replacements != null && replacements.size() > 0) {
3167                 final Runnable runnable = new Runnable() {
3168                     @Override
3169                     public void run() {
3170                         final Editable text = new SpannableStringBuilder(getText());
3171                         int i = 0;
3172                         for (final DrawableRecipientChip chip : recipients) {
3173                             final DrawableRecipientChip replacement = replacements.get(i);
3174                             if (replacement != null) {
3175                                 final RecipientEntry oldEntry = chip.getEntry();
3176                                 final RecipientEntry newEntry = replacement.getEntry();
3177                                 final boolean isBetter =
3178                                         RecipientAlternatesAdapter.getBetterRecipient(
3179                                                 oldEntry, newEntry) == newEntry;
3180 
3181                                 if (isBetter) {
3182                                     // Find the location of the chip in the text currently shown.
3183                                     final int start = text.getSpanStart(chip);
3184                                     if (start != -1) {
3185                                         // Replacing the entirety of what the chip represented,
3186                                         // including the extra space dividing it from other chips.
3187                                         final int end =
3188                                                 Math.min(text.getSpanEnd(chip) + 1, text.length());
3189                                         text.removeSpan(chip);
3190                                         // Make sure we always have just 1 space at the end to
3191                                         // separate this chip from the next chip.
3192                                         final SpannableString displayText =
3193                                                 new SpannableString(createAddressText(
3194                                                         replacement.getEntry()).trim() + " ");
3195                                         displayText.setSpan(replacement, 0,
3196                                                 displayText.length() - 1,
3197                                                 Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
3198                                         // Replace the old text we found with with the new display
3199                                         // text, which now may also contain the display name of the
3200                                         // recipient.
3201                                         text.replace(start, end, displayText);
3202                                         replacement.setOriginalText(displayText.toString());
3203                                         replacements.set(i, null);
3204 
3205                                         recipients.set(i, replacement);
3206                                     }
3207                                 }
3208                             }
3209                             i++;
3210                         }
3211                         setText(text);
3212                     }
3213                 };
3214 
3215                 if (Looper.myLooper() == Looper.getMainLooper()) {
3216                     runnable.run();
3217                 } else {
3218                     mHandler.post(runnable);
3219                 }
3220             }
3221         }
3222     }
3223 
3224     private class IndividualReplacementTask
3225             extends AsyncTask<ArrayList<DrawableRecipientChip>, Void, Void> {
3226         @Override
doInBackground(ArrayList<DrawableRecipientChip>.... params)3227         protected Void doInBackground(ArrayList<DrawableRecipientChip>... params) {
3228             // For each chip in the list, look up the matching contact.
3229             // If there is a match, replace that chip with the matching
3230             // chip.
3231             final ArrayList<DrawableRecipientChip> originalRecipients = params[0];
3232             ArrayList<String> addresses = new ArrayList<String>();
3233             for (DrawableRecipientChip chip : originalRecipients) {
3234                 if (chip != null) {
3235                     addresses.add(createAddressText(chip.getEntry()));
3236                 }
3237             }
3238             final BaseRecipientAdapter adapter = getAdapter();
3239             adapter.getMatchingRecipients(addresses, new RecipientMatchCallback() {
3240 
3241                         @Override
3242                         public void matchesFound(Map<String, RecipientEntry> entries) {
3243                             for (final DrawableRecipientChip temp : originalRecipients) {
3244                                 if (RecipientEntry.isCreatedRecipient(temp.getEntry()
3245                                         .getContactId())
3246                                         && getSpannable().getSpanStart(temp) != -1) {
3247                                     // Replace this.
3248                                     final RecipientEntry entry = createValidatedEntry(entries
3249                                             .get(tokenizeAddress(temp.getEntry().getDestination())
3250                                                     .toLowerCase()));
3251                                     if (entry != null) {
3252                                         mHandler.post(new Runnable() {
3253                                             @Override
3254                                             public void run() {
3255                                                 replaceChip(temp, entry);
3256                                             }
3257                                         });
3258                                     }
3259                                 }
3260                             }
3261                         }
3262 
3263                         @Override
3264                         public void matchesNotFound(final Set<String> unfoundAddresses) {
3265                             // No action required
3266                         }
3267                     });
3268             return null;
3269         }
3270     }
3271 
3272 
3273     /**
3274      * MoreImageSpan is a simple class created for tracking the existence of a
3275      * more chip across activity restarts/
3276      */
3277     private class MoreImageSpan extends ReplacementDrawableSpan {
MoreImageSpan(Drawable b)3278         public MoreImageSpan(Drawable b) {
3279             super(b);
3280             setExtraMargin(mLineSpacingExtra);
3281         }
3282     }
3283 
3284     @Override
onDown(MotionEvent e)3285     public boolean onDown(MotionEvent e) {
3286         return false;
3287     }
3288 
3289     @Override
onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY)3290     public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
3291         // Do nothing.
3292         return false;
3293     }
3294 
3295     @Override
onLongPress(MotionEvent event)3296     public void onLongPress(MotionEvent event) {
3297         if (mSelectedChip != null) {
3298             return;
3299         }
3300         float x = event.getX();
3301         float y = event.getY();
3302         final int offset = putOffsetInRange(x, y);
3303         DrawableRecipientChip currentChip = findChip(offset);
3304         if (currentChip != null) {
3305             if (mDragEnabled) {
3306                 // Start drag-and-drop for the selected chip.
3307                 startDrag(currentChip);
3308             } else {
3309                 // Copy the selected chip email address.
3310                 showCopyDialog(currentChip.getEntry().getDestination());
3311             }
3312         }
3313     }
3314 
3315     // The following methods are used to provide some functionality on older versions of Android
3316     // These methods were copied out of JB MR2's TextView
3317     /////////////////////////////////////////////////
supportGetOffsetForPosition(float x, float y)3318     private int supportGetOffsetForPosition(float x, float y) {
3319         if (getLayout() == null) return -1;
3320         final int line = supportGetLineAtCoordinate(y);
3321         return supportGetOffsetAtCoordinate(line, x);
3322     }
3323 
supportConvertToLocalHorizontalCoordinate(float x)3324     private float supportConvertToLocalHorizontalCoordinate(float x) {
3325         x -= getTotalPaddingLeft();
3326         // Clamp the position to inside of the view.
3327         x = Math.max(0.0f, x);
3328         x = Math.min(getWidth() - getTotalPaddingRight() - 1, x);
3329         x += getScrollX();
3330         return x;
3331     }
3332 
supportGetLineAtCoordinate(float y)3333     private int supportGetLineAtCoordinate(float y) {
3334         y -= getTotalPaddingLeft();
3335         // Clamp the position to inside of the view.
3336         y = Math.max(0.0f, y);
3337         y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y);
3338         y += getScrollY();
3339         return getLayout().getLineForVertical((int) y);
3340     }
3341 
supportGetOffsetAtCoordinate(int line, float x)3342     private int supportGetOffsetAtCoordinate(int line, float x) {
3343         x = supportConvertToLocalHorizontalCoordinate(x);
3344         return getLayout().getOffsetForHorizontal(line, x);
3345     }
3346     /////////////////////////////////////////////////
3347 
3348     /**
3349      * Enables drag-and-drop for chips.
3350      */
enableDrag()3351     public void enableDrag() {
3352         mDragEnabled = true;
3353     }
3354 
3355     /**
3356      * Starts drag-and-drop for the selected chip.
3357      */
startDrag(DrawableRecipientChip currentChip)3358     private void startDrag(DrawableRecipientChip currentChip) {
3359         String address = currentChip.getEntry().getDestination();
3360         ClipData data = ClipData.newPlainText(address, address + COMMIT_CHAR_COMMA);
3361 
3362         // Start drag mode.
3363         startDrag(data, new RecipientChipShadow(currentChip), null, 0);
3364 
3365         // Remove the current chip, so drag-and-drop will result in a move.
3366         // TODO (phamm): consider readd this chip if it's dropped outside a target.
3367         removeChip(currentChip);
3368     }
3369 
3370     /**
3371      * Handles drag event.
3372      */
3373     @Override
onDragEvent(@onNull DragEvent event)3374     public boolean onDragEvent(@NonNull DragEvent event) {
3375         switch (event.getAction()) {
3376             case DragEvent.ACTION_DRAG_STARTED:
3377                 // Only handle plain text drag and drop.
3378                 return event.getClipDescription().hasMimeType(ClipDescription.MIMETYPE_TEXT_PLAIN);
3379             case DragEvent.ACTION_DRAG_ENTERED:
3380                 requestFocus();
3381                 return true;
3382             case DragEvent.ACTION_DROP:
3383                 handlePasteClip(event.getClipData());
3384                 return true;
3385         }
3386         return false;
3387     }
3388 
3389     /**
3390      * Drag shadow for a {@link DrawableRecipientChip}.
3391      */
3392     private final class RecipientChipShadow extends DragShadowBuilder {
3393         private final DrawableRecipientChip mChip;
3394 
RecipientChipShadow(DrawableRecipientChip chip)3395         public RecipientChipShadow(DrawableRecipientChip chip) {
3396             mChip = chip;
3397         }
3398 
3399         @Override
onProvideShadowMetrics(@onNull Point shadowSize, @NonNull Point shadowTouchPoint)3400         public void onProvideShadowMetrics(@NonNull Point shadowSize,
3401                 @NonNull Point shadowTouchPoint) {
3402             Rect rect = mChip.getBounds();
3403             shadowSize.set(rect.width(), rect.height());
3404             shadowTouchPoint.set(rect.centerX(), rect.centerY());
3405         }
3406 
3407         @Override
onDrawShadow(@onNull Canvas canvas)3408         public void onDrawShadow(@NonNull Canvas canvas) {
3409             mChip.draw(canvas);
3410         }
3411     }
3412 
showCopyDialog(final String address)3413     private void showCopyDialog(final String address) {
3414         final Context context = getContext();
3415         if (!mAttachedToWindow || context == null || !(context instanceof Activity)) {
3416             return;
3417         }
3418 
3419         final DialogFragment fragment = CopyDialog.newInstance(address);
3420         fragment.show(((Activity) context).getFragmentManager(), CopyDialog.TAG);
3421     }
3422 
3423     @Override
onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY)3424     public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
3425         // Do nothing.
3426         return false;
3427     }
3428 
3429     @Override
onShowPress(MotionEvent e)3430     public void onShowPress(MotionEvent e) {
3431         // Do nothing.
3432     }
3433 
3434     @Override
onSingleTapUp(MotionEvent e)3435     public boolean onSingleTapUp(MotionEvent e) {
3436         // Do nothing.
3437         return false;
3438     }
3439 
isPhoneQuery()3440     protected boolean isPhoneQuery() {
3441         return getAdapter() != null
3442                 && getAdapter().getQueryType() == BaseRecipientAdapter.QUERY_TYPE_PHONE;
3443     }
3444 
3445     @Override
getAdapter()3446     public BaseRecipientAdapter getAdapter() {
3447         return (BaseRecipientAdapter) super.getAdapter();
3448     }
3449 
3450     /**
3451      * Append a new {@link RecipientEntry} to the end of the recipient chips, leaving any
3452      * unfinished text at the end.
3453      */
appendRecipientEntry(final RecipientEntry entry)3454     public void appendRecipientEntry(final RecipientEntry entry) {
3455         clearComposingText();
3456 
3457         final Editable editable = getText();
3458         int chipInsertionPoint = 0;
3459 
3460         // Find the end of last chip and see if there's any unchipified text.
3461         final DrawableRecipientChip[] recips = getSortedRecipients();
3462         if (recips != null && recips.length > 0) {
3463             final DrawableRecipientChip last = recips[recips.length - 1];
3464             // The chip will be inserted at the end of last chip + 1. All the unfinished text after
3465             // the insertion point will be kept untouched.
3466             chipInsertionPoint = editable.getSpanEnd(last) + 1;
3467         }
3468 
3469         final CharSequence chip = createChip(entry);
3470         if (chip != null) {
3471             editable.insert(chipInsertionPoint, chip);
3472         }
3473     }
3474 
3475     /**
3476      * Remove all chips matching the given RecipientEntry.
3477      */
removeRecipientEntry(final RecipientEntry entry)3478     public void removeRecipientEntry(final RecipientEntry entry) {
3479         final DrawableRecipientChip[] recips = getText()
3480                 .getSpans(0, getText().length(), DrawableRecipientChip.class);
3481 
3482         for (final DrawableRecipientChip recipient : recips) {
3483             final RecipientEntry existingEntry = recipient.getEntry();
3484             if (existingEntry != null && existingEntry.isValid() &&
3485                     existingEntry.isSamePerson(entry)) {
3486                 removeChip(recipient);
3487             }
3488         }
3489     }
3490 
setAlternatePopupAnchor(View v)3491     public void setAlternatePopupAnchor(View v) {
3492         mAlternatePopupAnchor = v;
3493     }
3494 
3495     @Override
setVisibility(int visibility)3496     public void setVisibility(int visibility) {
3497         super.setVisibility(visibility);
3498 
3499         if (visibility != GONE && mRequiresShrinkWhenNotGone) {
3500             mRequiresShrinkWhenNotGone = false;
3501             mHandler.post(mDelayedShrink);
3502         }
3503     }
3504 
3505     private static class ChipBitmapContainer {
3506         Bitmap bitmap;
3507         // information used for positioning the loaded icon
3508         boolean loadIcon = true;
3509         float left;
3510         float top;
3511         float right;
3512         float bottom;
3513         // information used for positioning the warning icon
3514         float warningIconLeft;
3515         float warningIconTop;
3516         float warningIconRight;
3517         float warningIconBottom;
3518     }
3519 }
3520