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