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