RecipientEditTextView.java revision e1bfc9218326c02ff51985dcb22ee9b0ae385807
1/*
2 * Copyright (C) 2011 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.ex.chips;
18
19import android.app.Dialog;
20import android.content.ClipData;
21import android.content.ClipboardManager;
22import android.content.Context;
23import android.content.DialogInterface;
24import android.content.DialogInterface.OnDismissListener;
25import android.graphics.Bitmap;
26import android.graphics.BitmapFactory;
27import android.graphics.Canvas;
28import android.graphics.Matrix;
29import android.graphics.Rect;
30import android.graphics.RectF;
31import android.graphics.drawable.BitmapDrawable;
32import android.graphics.drawable.Drawable;
33import android.os.AsyncTask;
34import android.os.Handler;
35import android.os.Message;
36import android.text.Editable;
37import android.text.InputType;
38import android.text.Layout;
39import android.text.Spannable;
40import android.text.SpannableString;
41import android.text.SpannableStringBuilder;
42import android.text.Spanned;
43import android.text.TextPaint;
44import android.text.TextUtils;
45import android.text.TextWatcher;
46import android.text.method.QwertyKeyListener;
47import android.text.style.ImageSpan;
48import android.text.util.Rfc822Token;
49import android.text.util.Rfc822Tokenizer;
50import android.util.AttributeSet;
51import android.util.Log;
52import android.view.ActionMode;
53import android.view.ActionMode.Callback;
54import android.view.GestureDetector;
55import android.view.KeyEvent;
56import android.view.LayoutInflater;
57import android.view.Menu;
58import android.view.MenuItem;
59import android.view.MotionEvent;
60import android.view.View;
61import android.view.View.OnClickListener;
62import android.view.ViewParent;
63import android.widget.AdapterView;
64import android.widget.AdapterView.OnItemClickListener;
65import android.widget.Filterable;
66import android.widget.ListAdapter;
67import android.widget.ListPopupWindow;
68import android.widget.ListView;
69import android.widget.MultiAutoCompleteTextView;
70import android.widget.ScrollView;
71import android.widget.TextView;
72
73import java.util.ArrayList;
74import java.util.Arrays;
75import java.util.Collection;
76import java.util.Collections;
77import java.util.Comparator;
78import java.util.HashMap;
79import java.util.HashSet;
80import java.util.Set;
81
82/**
83 * RecipientEditTextView is an auto complete text view for use with applications
84 * that use the new Chips UI for addressing a message to recipients.
85 */
86public class RecipientEditTextView extends MultiAutoCompleteTextView implements
87        OnItemClickListener, Callback, RecipientAlternatesAdapter.OnCheckedItemChangedListener,
88        GestureDetector.OnGestureListener, OnDismissListener, OnClickListener {
89
90    private static final String TAG = "RecipientEditTextView";
91
92    // TODO: get correct number/ algorithm from with UX.
93    private static final int CHIP_LIMIT = 2;
94
95    private Drawable mChipBackground = null;
96
97    private Drawable mChipDelete = null;
98
99    private int mChipPadding;
100
101    private Tokenizer mTokenizer;
102
103    private Drawable mChipBackgroundPressed;
104
105    private RecipientChip mSelectedChip;
106
107    private int mAlternatesLayout;
108
109    private Bitmap mDefaultContactPhoto;
110
111    private ImageSpan mMoreChip;
112
113    private TextView mMoreItem;
114
115    private final ArrayList<String> mPendingChips = new ArrayList<String>();
116
117    private float mChipHeight;
118
119    private float mChipFontSize;
120
121    private Validator mValidator;
122
123    private Drawable mInvalidChipBackground;
124
125    private Handler mHandler;
126
127    private static int DISMISS = "dismiss".hashCode();
128
129    private static final long DISMISS_DELAY = 300;
130
131    private int mPendingChipsCount = 0;
132
133    private static int sSelectedTextColor = -1;
134
135    private static final char COMMIT_CHAR_COMMA = ',';
136
137    private static final char COMMIT_CHAR_SEMICOLON = ';';
138
139    private static final char COMMIT_CHAR_SPACE = ' ';
140
141    private ListPopupWindow mAlternatesPopup;
142
143    private ListPopupWindow mAddressPopup;
144
145    private ArrayList<RecipientChip> mTemporaryRecipients;
146
147    private ArrayList<RecipientChip> mRemovedSpans;
148
149    private boolean mShouldShrink = true;
150
151    // Chip copy fields.
152    private GestureDetector mGestureDetector;
153
154    private Dialog mCopyDialog;
155
156    private int mCopyViewRes;
157
158    private String mCopyAddress;
159
160    /**
161     * Used with {@link #mAlternatesPopup}. Handles clicks to alternate addresses for a
162     * selected chip.
163     */
164    private OnItemClickListener mAlternatesListener;
165
166    private int mCheckedItem;
167    private TextWatcher mTextWatcher;
168
169    private ScrollView mScrollView;
170
171    private boolean mTried;
172
173    private final Runnable mAddTextWatcher = new Runnable() {
174        @Override
175        public void run() {
176            if (mTextWatcher == null) {
177                mTextWatcher = new RecipientTextWatcher();
178                addTextChangedListener(mTextWatcher);
179            }
180        }
181    };
182
183    private IndividualReplacementTask mIndividualReplacements;
184
185    private Runnable mHandlePendingChips = new Runnable() {
186
187        @Override
188        public void run() {
189            handlePendingChips();
190        }
191
192    };
193
194    public RecipientEditTextView(Context context, AttributeSet attrs) {
195        super(context, attrs);
196        if (sSelectedTextColor == -1) {
197            sSelectedTextColor = context.getResources().getColor(android.R.color.white);
198        }
199        mAlternatesPopup = new ListPopupWindow(context);
200        mAddressPopup = new ListPopupWindow(context);
201        mAlternatesListener = new OnItemClickListener() {
202            @Override
203            public void onItemClick(AdapterView<?> adapterView,View view, int position,
204                    long rowId) {
205                mAlternatesPopup.setOnItemClickListener(null);
206                replaceChip(mSelectedChip, ((RecipientAlternatesAdapter) adapterView.getAdapter())
207                        .getRecipientEntry(position));
208                Message delayed = Message.obtain(mHandler, DISMISS);
209                delayed.obj = mAlternatesPopup;
210                mHandler.sendMessageDelayed(delayed, DISMISS_DELAY);
211                clearComposingText();
212            }
213        };
214        setInputType(getInputType() | InputType.TYPE_TEXT_FLAG_NO_SUGGESTIONS);
215        setOnItemClickListener(this);
216        setCustomSelectionActionModeCallback(this);
217        mHandler = new Handler() {
218            @Override
219            public void handleMessage(Message msg) {
220                if (msg.what == DISMISS) {
221                    ((ListPopupWindow) msg.obj).dismiss();
222                    return;
223                }
224                super.handleMessage(msg);
225            }
226        };
227        mTextWatcher = new RecipientTextWatcher();
228        addTextChangedListener(mTextWatcher);
229        mGestureDetector = new GestureDetector(context, this);
230    }
231
232    @Override
233    public <T extends ListAdapter & Filterable> void setAdapter(T adapter) {
234        super.setAdapter(adapter);
235        if (adapter == null) {
236            return;
237        }
238    }
239
240    @Override
241    public void onSelectionChanged(int start, int end) {
242        // When selection changes, see if it is inside the chips area.
243        // If so, move the cursor back after the chips again.
244        Spannable span = getSpannable();
245        int textLength = getText().length();
246        RecipientChip[] chips = span.getSpans(start, textLength, RecipientChip.class);
247        if (chips != null && chips.length > 0) {
248            if (chips != null && chips.length > 0) {
249                // Grab the last chip and set the cursor to after it.
250                setSelection(Math.min(span.getSpanEnd(chips[chips.length - 1]) + 1, textLength));
251            }
252        }
253        super.onSelectionChanged(start, end);
254    }
255
256    /**
257     * Convenience method: Append the specified text slice to the TextView's
258     * display buffer, upgrading it to BufferType.EDITABLE if it was
259     * not already editable. Commas are excluded as they are added automatically
260     * by the view.
261     */
262    @Override
263    public void append(CharSequence text, int start, int end) {
264        // We don't care about watching text changes while appending.
265        if (mTextWatcher != null) {
266            removeTextChangedListener(mTextWatcher);
267        }
268        super.append(text, start, end);
269        if (!TextUtils.isEmpty(text) && TextUtils.getTrimmedLength(text) > 0) {
270            final String displayString = (String) text;
271            int seperatorPos = displayString.indexOf(COMMIT_CHAR_COMMA);
272            if (seperatorPos != 0 && !TextUtils.isEmpty(displayString)
273                    && TextUtils.getTrimmedLength(displayString) > 0) {
274                mPendingChipsCount++;
275                mPendingChips.add((String)text);
276            }
277        }
278        // Put a message on the queue to make sure we ALWAYS handle pending chips.
279        if (mPendingChipsCount > 0) {
280            postHandlePendingChips();
281        }
282        mHandler.post(mAddTextWatcher);
283    }
284
285    @Override
286    public void onFocusChanged(boolean hasFocus, int direction, Rect previous) {
287        if (!hasFocus) {
288            shrink();
289            dismissDropDown();
290        } else {
291            expand();
292            scrollLineIntoView(getLineCount());
293        }
294    }
295
296    private void shrink() {
297        if (mSelectedChip != null) {
298            clearSelectedChip();
299        } else {
300            // Reset any pending chips as they would have been handled
301            // when the field lost focus.
302            if (mPendingChipsCount > 0) {
303                postHandlePendingChips();
304            } else {
305                Editable editable = getText();
306                int end = getSelectionEnd();
307                int start = mTokenizer.findTokenStart(editable, end);
308                RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class);
309                if ((chips == null || chips.length == 0)) {
310                    int whatEnd = mTokenizer.findTokenEnd(getText(), start);
311                    // In the middle of chip; treat this as an edit
312                    // and commit the whole token.
313                    if (whatEnd != getSelectionEnd()) {
314                        handleEdit(start, whatEnd);
315                    } else {
316                        commitChip(start, end, editable);
317                    }
318                }
319            }
320            mHandler.post(mAddTextWatcher);
321        }
322        createMoreChip();
323    }
324
325    private void expand() {
326        removeMoreChip();
327        setCursorVisible(true);
328        Editable text = getText();
329        setSelection(text != null && text.length() > 0 ? text.length() : 0);
330        // If there are any temporary chips, try replacing them now that the user
331        // has expanded the field.
332        if (mTemporaryRecipients != null && mTemporaryRecipients.size() > 0) {
333            new RecipientReplacementTask().execute();
334            mTemporaryRecipients = null;
335        }
336    }
337
338    private CharSequence ellipsizeText(CharSequence text, TextPaint paint, float maxWidth) {
339        paint.setTextSize(mChipFontSize);
340        if (maxWidth <= 0 && Log.isLoggable(TAG, Log.DEBUG)) {
341            Log.d(TAG, "Max width is negative: " + maxWidth);
342        }
343        return TextUtils.ellipsize(text, paint, maxWidth,
344                TextUtils.TruncateAt.END);
345    }
346
347    private Bitmap createSelectedChip(RecipientEntry contact, TextPaint paint, Layout layout) {
348        // Ellipsize the text so that it takes AT MOST the entire width of the
349        // autocomplete text entry area. Make sure to leave space for padding
350        // on the sides.
351        int height = (int) mChipHeight;
352        int deleteWidth = height;
353        CharSequence ellipsizedText = ellipsizeText(contact.getDisplayName(), paint,
354                calculateAvailableWidth(true) - deleteWidth);
355
356        // Make sure there is a minimum chip width so the user can ALWAYS
357        // tap a chip without difficulty.
358        int width = Math.max(deleteWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0,
359                ellipsizedText.length()))
360                + (mChipPadding * 2) + deleteWidth);
361
362        // Create the background of the chip.
363        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
364        Canvas canvas = new Canvas(tmpBitmap);
365        if (mChipBackgroundPressed != null) {
366            mChipBackgroundPressed.setBounds(0, 0, width, height);
367            mChipBackgroundPressed.draw(canvas);
368            paint.setColor(sSelectedTextColor);
369            // Align the display text with where the user enters text.
370            canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding, height
371                    - Math.abs(height - mChipFontSize)/2, paint);
372            // Make the delete a square.
373            mChipDelete.setBounds(width - deleteWidth, 0, width, height);
374            mChipDelete.draw(canvas);
375        } else {
376            Log.w(TAG, "Unable to draw a background for the chips as it was never set");
377        }
378        return tmpBitmap;
379    }
380
381
382    /**
383     * Get the background drawable for a RecipientChip.
384     */
385    public Drawable getChipBackground(RecipientEntry contact) {
386        return (mValidator != null && mValidator.isValid(contact.getDestination())) ?
387                mChipBackground : mInvalidChipBackground;
388    }
389
390    private Bitmap createUnselectedChip(RecipientEntry contact, TextPaint paint, Layout layout) {
391        // Ellipsize the text so that it takes AT MOST the entire width of the
392        // autocomplete text entry area. Make sure to leave space for padding
393        // on the sides.
394        int height = (int) mChipHeight;
395        int iconWidth = height;
396        String displayText =
397            !TextUtils.isEmpty(contact.getDisplayName()) ? contact.getDisplayName() :
398            !TextUtils.isEmpty(contact.getDestination()) ? contact.getDestination() : "";
399        CharSequence ellipsizedText = ellipsizeText(displayText, paint,
400                calculateAvailableWidth(false) - iconWidth);
401        // Make sure there is a minimum chip width so the user can ALWAYS
402        // tap a chip without difficulty.
403        int width = Math.max(iconWidth * 2, (int) Math.floor(paint.measureText(ellipsizedText, 0,
404                ellipsizedText.length()))
405                + (mChipPadding * 2) + iconWidth);
406
407        // Create the background of the chip.
408        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
409        Canvas canvas = new Canvas(tmpBitmap);
410        Drawable background = getChipBackground(contact);
411        if (background != null) {
412            background.setBounds(0, 0, width, height);
413            background.draw(canvas);
414
415            // Don't draw photos for recipients that have been typed in.
416            if (contact.getContactId() != RecipientEntry.INVALID_CONTACT) {
417                byte[] photoBytes = contact.getPhotoBytes();
418                // There may not be a photo yet if anything but the first contact address
419                // was selected.
420                if (photoBytes == null && contact.getPhotoThumbnailUri() != null) {
421                    // TODO: cache this in the recipient entry?
422                    ((BaseRecipientAdapter) getAdapter()).fetchPhoto(contact, contact
423                            .getPhotoThumbnailUri());
424                    photoBytes = contact.getPhotoBytes();
425                }
426
427                Bitmap photo;
428                if (photoBytes != null) {
429                    photo = BitmapFactory.decodeByteArray(photoBytes, 0, photoBytes.length);
430                } else {
431                    // TODO: can the scaled down default photo be cached?
432                    photo = mDefaultContactPhoto;
433                }
434                // Draw the photo on the left side.
435                Matrix matrix = new Matrix();
436                RectF src = new RectF(0, 0, photo.getWidth(), photo.getHeight());
437                RectF dst = new RectF(width - iconWidth, 0, width, height);
438                matrix.setRectToRect(src, dst, Matrix.ScaleToFit.CENTER);
439                canvas.drawBitmap(photo, matrix, paint);
440            } else {
441                // Don't leave any space for the icon. It isn't being drawn.
442                iconWidth = 0;
443            }
444
445            // Align the display text with where the user enters text.
446            canvas.drawText(ellipsizedText, 0, ellipsizedText.length(), mChipPadding,
447                    height - Math.abs(height - mChipFontSize) / 2, paint);
448        } else {
449            Log.w(TAG, "Unable to draw a background for the chips as it was never set");
450        }
451        return tmpBitmap;
452    }
453
454    public RecipientChip constructChipSpan(RecipientEntry contact, int offset, boolean pressed)
455            throws NullPointerException {
456        if (mChipBackground == null) {
457            throw new NullPointerException(
458                    "Unable to render any chips as setChipDimensions was not called.");
459        }
460        Layout layout = getLayout();
461
462        TextPaint paint = getPaint();
463        float defaultSize = paint.getTextSize();
464        int defaultColor = paint.getColor();
465
466        Bitmap tmpBitmap;
467        if (pressed) {
468            tmpBitmap = createSelectedChip(contact, paint, layout);
469
470        } else {
471            tmpBitmap = createUnselectedChip(contact, paint, layout);
472        }
473
474        // Pass the full text, un-ellipsized, to the chip.
475        Drawable result = new BitmapDrawable(getResources(), tmpBitmap);
476        result.setBounds(0, 0, tmpBitmap.getWidth(), tmpBitmap.getHeight());
477        RecipientChip recipientChip = new RecipientChip(result, contact, offset);
478        // Return text to the original size.
479        paint.setTextSize(defaultSize);
480        paint.setColor(defaultColor);
481        return recipientChip;
482    }
483
484    /**
485     * Calculate the bottom of the line the chip will be located on using:
486     * 1) which line the chip appears on
487     * 2) the height of a chip
488     * 3) padding built into the edit text view
489     */
490    private int calculateOffsetFromBottom(int line) {
491        // Line offsets start at zero.
492        int actualLine = getLineCount() - (line + 1);
493        return -((actualLine * ((int)mChipHeight) + getPaddingBottom()) + getPaddingTop());
494    }
495
496    /**
497     * Get the max amount of space a chip can take up. The formula takes into
498     * account the width of the EditTextView, any view padding, and padding
499     * that will be added to the chip.
500     */
501    private float calculateAvailableWidth(boolean pressed) {
502        return getWidth() - getPaddingLeft() - getPaddingRight() - (mChipPadding * 2);
503    }
504
505    /**
506     * Set all chip dimensions and resources. This has to be done from the
507     * application as this is a static library.
508     * @param chipBackground
509     * @param chipBackgroundPressed
510     * @param invalidChip
511     * @param chipDelete
512     * @param defaultContact
513     * @param moreResource
514     * @param alternatesLayout
515     * @param chipHeight
516     * @param padding Padding around the text in a chip
517     * @param chipFontSize
518     * @param copyViewRes
519     */
520    public void setChipDimensions(Drawable chipBackground, Drawable chipBackgroundPressed,
521            Drawable invalidChip, Drawable chipDelete, Bitmap defaultContact, int moreResource,
522            int alternatesLayout, float chipHeight, float padding,
523            float chipFontSize, int copyViewRes) {
524        mChipBackground = chipBackground;
525        mChipBackgroundPressed = chipBackgroundPressed;
526        mChipDelete = chipDelete;
527        mChipPadding = (int) padding;
528        mAlternatesLayout = alternatesLayout;
529        mDefaultContactPhoto = defaultContact;
530        mMoreItem = (TextView) LayoutInflater.from(getContext()).inflate(moreResource, null);
531        mChipHeight = chipHeight;
532        mChipFontSize = chipFontSize;
533        mInvalidChipBackground = invalidChip;
534        mCopyViewRes = copyViewRes;
535    }
536
537    /**
538     * Set whether to shrink the recipients field such that at most
539     * one line of recipients chips are shown when the field loses
540     * focus. By default, the number of displayed recipients will be
541     * limited and a "more" chip will be shown when focus is lost.
542     * @param shrink
543     */
544    public void setOnFocusListShrinkRecipients(boolean shrink) {
545        mShouldShrink = shrink;
546    }
547
548    @Override
549    public void onSizeChanged(int width, int height, int oldw, int oldh) {
550        super.onSizeChanged(width, height, oldw, oldh);
551        if (width != 0 && height != 0 && mPendingChipsCount > 0) {
552            postHandlePendingChips();
553        }
554        // Try to find the scroll view parent, if it exists.
555        if (mScrollView == null && !mTried) {
556            ViewParent parent = getParent();
557            while (parent != null && !(parent instanceof ScrollView)) {
558                parent = parent.getParent();
559            }
560            if (parent != null) {
561                mScrollView = (ScrollView) parent;
562            }
563            mTried = true;
564        }
565    }
566
567    private void postHandlePendingChips() {
568        mHandler.removeCallbacks(mHandlePendingChips);
569        mHandler.post(mHandlePendingChips);
570    }
571
572    private void handlePendingChips() {
573        if (mPendingChipsCount <= 0) {
574            return;
575        }
576        if (getWidth() <= 0) {
577            // The widget has not been sized yet.
578            // This will be called as a result of onSizeChanged
579            // at a later point.
580            return;
581        }
582        synchronized (mPendingChips) {
583            mTemporaryRecipients = new ArrayList<RecipientChip>(mPendingChipsCount);
584            Editable editable = getText();
585            // Tokenize!
586            for (int i = 0; i < mPendingChips.size(); i++) {
587                String current = mPendingChips.get(i);
588                int tokenStart = editable.toString().indexOf(current);
589                int tokenEnd = tokenStart + current.length();
590                if (tokenStart >= 0) {
591                    // When we have a valid token, include it with the token
592                    // to the left.
593                    if (tokenEnd < editable.length() - 2
594                            && editable.charAt(tokenEnd) == COMMIT_CHAR_COMMA) {
595                        tokenEnd++;
596                    }
597                    createReplacementChip(tokenStart, tokenEnd, editable);
598                }
599                mPendingChipsCount--;
600            }
601            sanitizeSpannable();
602            if (mTemporaryRecipients != null
603                    && mTemporaryRecipients.size() <= RecipientAlternatesAdapter.MAX_LOOKUPS) {
604                if (hasFocus() || mTemporaryRecipients.size() < CHIP_LIMIT) {
605                    new RecipientReplacementTask().execute();
606                    mTemporaryRecipients = null;
607                } else {
608                    // Create the "more" chip
609                    mIndividualReplacements = new IndividualReplacementTask();
610                    mIndividualReplacements.execute(new ArrayList<RecipientChip>(
611                            mTemporaryRecipients.subList(0, CHIP_LIMIT)));
612
613                    createMoreChip();
614                }
615            } else {
616                // There are too many recipients to look up, so just fall back
617                // to
618                // showing addresses for all of them.
619                mTemporaryRecipients = null;
620                createMoreChip();
621            }
622            mPendingChipsCount = 0;
623            mPendingChips.clear();
624        }
625    }
626
627    /**
628     * Remove any characters after the last valid chip.
629     */
630    private void sanitizeSpannable() {
631        // Find the last chip; eliminate any commit characters after it.
632        RecipientChip[] chips = getRecipients();
633        if (chips != null && chips.length > 0) {
634            int end;
635            ImageSpan lastSpan;
636            if (mMoreChip != null) {
637                lastSpan = mMoreChip;
638            } else {
639                lastSpan = chips[chips.length - 1];
640            }
641            end = getSpannable().getSpanEnd(lastSpan);
642            Editable editable = getText();
643            int length = editable.length();
644            if (length > end) {
645                // See what characters occur after that and eliminate them.
646                if (Log.isLoggable(TAG, Log.DEBUG)) {
647                    Log.d(TAG, "There were extra characters after the last tokenizable entry."
648                            + editable);
649                }
650                editable.delete(end + 1, length);
651            }
652        }
653    }
654
655    /**
656     * Create a chip that represents just the email address of a recipient. At some later
657     * point, this chip will be attached to a real contact entry, if one exists.
658     */
659    private void createReplacementChip(int tokenStart, int tokenEnd, Editable editable) {
660        if (alreadyHasChip(tokenStart, tokenEnd)) {
661            // There is already a chip present at this location.
662            // Don't recreate it.
663            return;
664        }
665        String token = editable.toString().substring(tokenStart, tokenEnd);
666        int commitCharIndex = token.trim().lastIndexOf(COMMIT_CHAR_COMMA);
667        if (commitCharIndex == token.length() - 1) {
668            token = token.substring(0, token.length() - 1);
669        }
670        RecipientEntry entry = createTokenizedEntry(token);
671        if (entry != null) {
672            String destText = entry.getDestination();
673            destText = (String) mTokenizer.terminateToken(destText);
674            // Always leave a blank space at the end of a chip.
675            int textLength = destText.length() - 1;
676            SpannableString chipText = new SpannableString(destText);
677            int end = getSelectionEnd();
678            int start = mTokenizer.findTokenStart(getText(), end);
679            RecipientChip chip = null;
680            try {
681                chip = constructChipSpan(entry, start, false);
682                chipText.setSpan(chip, 0, textLength, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
683            } catch (NullPointerException e) {
684                Log.e(TAG, e.getMessage(), e);
685            }
686
687            editable.replace(tokenStart, tokenEnd, chipText);
688            // Add this chip to the list of entries "to replace"
689            if (chip != null) {
690                chip.setOriginalText(chipText.toString());
691                mTemporaryRecipients.add(chip);
692            }
693        }
694    }
695
696    private RecipientEntry createTokenizedEntry(String token) {
697        if (TextUtils.isEmpty(token)) {
698            return null;
699        }
700        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(token);
701        String display = null;
702        if (isValid(token) && tokens != null && tokens.length > 0) {
703            // If we can get a name from tokenizing, then generate an entry from
704            // this.
705            display = tokens[0].getName();
706            if (!TextUtils.isEmpty(display)) {
707                return RecipientEntry.constructGeneratedEntry(display, token);
708            } else {
709                display = tokens[0].getAddress();
710                if (!TextUtils.isEmpty(display)) {
711                    return RecipientEntry.constructFakeEntry(display);
712                }
713            }
714        }
715        // Unable to validate the token or to create a valid token from it.
716        // Just create a chip the user can edit.
717        if (mValidator != null && !mValidator.isValid(token)) {
718            // Try fixing up the entry using the validator.
719            token = mValidator.fixText(token).toString();
720            if (!TextUtils.isEmpty(token)) {
721                // protect against the case of a validator with a null domain,
722                // which doesn't add a domain to the token
723                Rfc822Token[] tokenized = Rfc822Tokenizer.tokenize(token);
724                if (tokenized.length > 0) {
725                    token = tokenized[0].getAddress();
726                }
727            }
728        }
729        // Otherwise, fallback to just creating an editable email address chip.
730        return RecipientEntry.constructFakeEntry(token);
731    }
732
733    private boolean isValid(String text) {
734        return mValidator == null ? true : mValidator.isValid(text);
735    }
736
737    private String tokenizeAddress(String destination) {
738        Rfc822Token[] tokens = Rfc822Tokenizer.tokenize(destination);
739        if (tokens != null && tokens.length > 0) {
740            return tokens[0].getAddress();
741        }
742        return destination;
743    }
744
745    @Override
746    public void setTokenizer(Tokenizer tokenizer) {
747        mTokenizer = tokenizer;
748        super.setTokenizer(mTokenizer);
749    }
750
751    @Override
752    public void setValidator(Validator validator) {
753        mValidator = validator;
754        super.setValidator(validator);
755    }
756
757    /**
758     * We cannot use the default mechanism for replaceText. Instead,
759     * we override onItemClickListener so we can get all the associated
760     * contact information including display text, address, and id.
761     */
762    @Override
763    protected void replaceText(CharSequence text) {
764        return;
765    }
766
767    /**
768     * Dismiss any selected chips when the back key is pressed.
769     */
770    @Override
771    public boolean onKeyPreIme(int keyCode, KeyEvent event) {
772        if (keyCode == KeyEvent.KEYCODE_BACK) {
773            clearSelectedChip();
774        }
775        return super.onKeyPreIme(keyCode, event);
776    }
777
778    /**
779     * Monitor key presses in this view to see if the user types
780     * any commit keys, which consist of ENTER, TAB, or DPAD_CENTER.
781     * If the user has entered text that has contact matches and types
782     * a commit key, create a chip from the topmost matching contact.
783     * If the user has entered text that has no contact matches and types
784     * a commit key, then create a chip from the text they have entered.
785     */
786    @Override
787    public boolean onKeyUp(int keyCode, KeyEvent event) {
788        switch (keyCode) {
789            case KeyEvent.KEYCODE_ENTER:
790            case KeyEvent.KEYCODE_DPAD_CENTER:
791                if (event.hasNoModifiers()) {
792                    if (commitDefault()) {
793                        return true;
794                    }
795                    if (mSelectedChip != null) {
796                        clearSelectedChip();
797                        return true;
798                    } else if (focusNext()) {
799                        return true;
800                    }
801                }
802                break;
803            case KeyEvent.KEYCODE_TAB:
804                if (event.hasNoModifiers()) {
805                    if (mSelectedChip != null) {
806                        clearSelectedChip();
807                    } else {
808                        commitDefault();
809                    }
810                    if (focusNext()) {
811                        return true;
812                    }
813                }
814        }
815        return super.onKeyUp(keyCode, event);
816    }
817
818    private boolean focusNext() {
819        View next = focusSearch(View.FOCUS_DOWN);
820        if (next != null) {
821            next.requestFocus();
822            return true;
823        }
824        return false;
825    }
826
827    /**
828     * Create a chip from the default selection. If the popup is showing, the
829     * default is the first item in the popup suggestions list. Otherwise, it is
830     * whatever the user had typed in. End represents where the the tokenizer
831     * should search for a token to turn into a chip.
832     * @return If a chip was created from a real contact.
833     */
834    private boolean commitDefault() {
835        Editable editable = getText();
836        int end = getSelectionEnd();
837        int start = mTokenizer.findTokenStart(editable, end);
838
839        if (shouldCreateChip(start, end)) {
840            int whatEnd = mTokenizer.findTokenEnd(getText(), start);
841            // In the middle of chip; treat this as an edit
842            // and commit the whole token.
843            if (whatEnd != getSelectionEnd()) {
844                handleEdit(start, whatEnd);
845                return true;
846            }
847            return commitChip(start, end , editable);
848        }
849        return false;
850    }
851
852    private void commitByCharacter() {
853        Editable editable = getText();
854        int end = getSelectionEnd();
855        int start = mTokenizer.findTokenStart(editable, end);
856        if (shouldCreateChip(start, end)) {
857            commitChip(start, end, editable);
858        }
859        setSelection(getText().length());
860    }
861
862    private boolean commitChip(int start, int end, Editable editable) {
863        if (getAdapter().getCount() > 0 && enoughToFilter()) {
864            // choose the first entry.
865            submitItemAtPosition(0);
866            dismissDropDown();
867            return true;
868        } else {
869            int tokenEnd = mTokenizer.findTokenEnd(editable, start);
870            String text = editable.toString().substring(start, tokenEnd).trim();
871            clearComposingText();
872            if (text != null && text.length() > 0 && !text.equals(" ")) {
873                RecipientEntry entry = createTokenizedEntry(text);
874                if (entry != null) {
875                    QwertyKeyListener.markAsReplaced(editable, start, end, "");
876                    CharSequence chipText = createChip(entry, false);
877                    editable.replace(start, end, chipText);
878                }
879                dismissDropDown();
880                return true;
881            }
882        }
883        return false;
884    }
885
886    private boolean shouldCreateChip(int start, int end) {
887        return hasFocus() && enoughToFilter() && !alreadyHasChip(start, end);
888    }
889
890    private boolean alreadyHasChip(int start, int end) {
891        RecipientChip[] chips = getSpannable().getSpans(start, end, RecipientChip.class);
892        if ((chips == null || chips.length == 0)) {
893            return false;
894        }
895        return true;
896    }
897
898    private void handleEdit(int start, int end) {
899        // This is in the middle of a chip, so select out the whole chip
900        // and commit it.
901        Editable editable = getText();
902        setSelection(end);
903        String text = getText().toString().substring(start, end);
904        RecipientEntry entry = RecipientEntry.constructFakeEntry(text);
905        QwertyKeyListener.markAsReplaced(editable, start, end, "");
906        CharSequence chipText = createChip(entry, false);
907        editable.replace(start, getSelectionEnd(), chipText);
908        dismissDropDown();
909    }
910
911    /**
912     * If there is a selected chip, delegate the key events
913     * to the selected chip.
914     */
915    @Override
916    public boolean onKeyDown(int keyCode, KeyEvent event) {
917        if (mSelectedChip != null && keyCode == KeyEvent.KEYCODE_DEL) {
918            if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
919                mAlternatesPopup.dismiss();
920            }
921            removeChip(mSelectedChip);
922        }
923
924        if (keyCode == KeyEvent.KEYCODE_ENTER && event.hasNoModifiers()) {
925            return true;
926        }
927
928        return super.onKeyDown(keyCode, event);
929    }
930
931    private Spannable getSpannable() {
932        return getText();
933    }
934
935    private int getChipStart(RecipientChip chip) {
936        return getSpannable().getSpanStart(chip);
937    }
938
939    private int getChipEnd(RecipientChip chip) {
940        return getSpannable().getSpanEnd(chip);
941    }
942
943    /**
944     * Instead of filtering on the entire contents of the edit box,
945     * this subclass method filters on the range from
946     * {@link Tokenizer#findTokenStart} to {@link #getSelectionEnd}
947     * if the length of that range meets or exceeds {@link #getThreshold}
948     * and makes sure that the range is not already a Chip.
949     */
950    @Override
951    protected void performFiltering(CharSequence text, int keyCode) {
952        if (enoughToFilter()) {
953            int end = getSelectionEnd();
954            int start = mTokenizer.findTokenStart(text, end);
955            // If this is a RecipientChip, don't filter
956            // on its contents.
957            Spannable span = getSpannable();
958            RecipientChip[] chips = span.getSpans(start, end, RecipientChip.class);
959            if (chips != null && chips.length > 0) {
960                return;
961            }
962        }
963        super.performFiltering(text, keyCode);
964    }
965
966    private void clearSelectedChip() {
967        if (mSelectedChip != null) {
968            unselectChip(mSelectedChip);
969            mSelectedChip = null;
970        }
971        setCursorVisible(true);
972    }
973
974    /**
975     * Monitor touch events in the RecipientEditTextView.
976     * If the view does not have focus, any tap on the view
977     * will just focus the view. If the view has focus, determine
978     * if the touch target is a recipient chip. If it is and the chip
979     * is not selected, select it and clear any other selected chips.
980     * If it isn't, then select that chip.
981     */
982    @Override
983    public boolean onTouchEvent(MotionEvent event) {
984        if (!isFocused()) {
985            // Ignore any chip taps until this view is focused.
986            return super.onTouchEvent(event);
987        }
988
989        boolean handled = super.onTouchEvent(event);
990        int action = event.getAction();
991        boolean chipWasSelected = false;
992        if (action == MotionEvent.ACTION_DOWN && mSelectedChip == null) {
993            mGestureDetector.onTouchEvent(event);
994        }
995        if (mCopyDialog == null && action == MotionEvent.ACTION_UP) {
996            float x = event.getX();
997            float y = event.getY();
998            int offset = putOffsetInRange(getOffsetForPosition(x, y));
999            RecipientChip currentChip = findChip(offset);
1000            if (currentChip != null) {
1001                if (action == MotionEvent.ACTION_UP) {
1002                    if (mSelectedChip != null && mSelectedChip != currentChip) {
1003                        clearSelectedChip();
1004                        mSelectedChip = selectChip(currentChip);
1005                    } else if (mSelectedChip == null) {
1006                        // Selection may have moved due to the tap event,
1007                        // but make sure we correctly reset selection to the
1008                        // end so that any unfinished chips are committed.
1009                        setSelection(getText().length());
1010                        commitDefault();
1011                        mSelectedChip = selectChip(currentChip);
1012                    } else {
1013                        onClick(mSelectedChip, offset, x, y);
1014                    }
1015                }
1016                chipWasSelected = true;
1017                handled = true;
1018            }
1019        }
1020        if (action == MotionEvent.ACTION_UP && !chipWasSelected) {
1021            clearSelectedChip();
1022        }
1023        return handled;
1024    }
1025
1026    private void scrollLineIntoView(int line) {
1027        if (mScrollView != null) {
1028            mScrollView.scrollBy(0, calculateOffsetFromBottom(line));
1029        }
1030    }
1031
1032    private void showAlternates(RecipientChip currentChip, ListPopupWindow alternatesPopup,
1033            int width, Context context) {
1034        int line = getLayout().getLineForOffset(getChipStart(currentChip));
1035        int bottom = calculateOffsetFromBottom(line);
1036        // Align the alternates popup with the left side of the View,
1037        // regardless of the position of the chip tapped.
1038        alternatesPopup.setWidth(width);
1039        alternatesPopup.setAnchorView(this);
1040        alternatesPopup.setVerticalOffset(bottom);
1041        alternatesPopup.setAdapter(createAlternatesAdapter(currentChip));
1042        alternatesPopup.setOnItemClickListener(mAlternatesListener);
1043        alternatesPopup.show();
1044        ListView listView = alternatesPopup.getListView();
1045        listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
1046        // Checked item would be -1 if the adapter has not
1047        // loaded the view that should be checked yet. The
1048        // variable will be set correctly when onCheckedItemChanged
1049        // is called in a separate thread.
1050        if (mCheckedItem != -1) {
1051            listView.setItemChecked(mCheckedItem, true);
1052            mCheckedItem = -1;
1053        }
1054    }
1055
1056    private ListAdapter createAlternatesAdapter(RecipientChip chip) {
1057        return new RecipientAlternatesAdapter(getContext(), chip.getContactId(), chip.getDataId(),
1058                mAlternatesLayout, this);
1059    }
1060
1061    private ListAdapter createSingleAddressAdapter(RecipientChip currentChip) {
1062        return new SingleRecipientArrayAdapter(getContext(), mAlternatesLayout, currentChip
1063                .getEntry());
1064    }
1065
1066    @Override
1067    public void onCheckedItemChanged(int position) {
1068        ListView listView = mAlternatesPopup.getListView();
1069        if (listView != null && listView.getCheckedItemCount() == 0) {
1070            listView.setItemChecked(position, true);
1071        } else {
1072            mCheckedItem = position;
1073        }
1074    }
1075
1076    // TODO: This algorithm will need a lot of tweaking after more people have used
1077    // the chips ui. This attempts to be "forgiving" to fat finger touches by favoring
1078    // what comes before the finger.
1079    private int putOffsetInRange(int o) {
1080        int offset = o;
1081        Editable text = getText();
1082        int length = text.length();
1083        // Remove whitespace from end to find "real end"
1084        int realLength = length;
1085        for (int i = length - 1; i >= 0; i--) {
1086            if (text.charAt(i) == ' ') {
1087                realLength--;
1088            } else {
1089                break;
1090            }
1091        }
1092
1093        // If the offset is beyond or at the end of the text,
1094        // leave it alone.
1095        if (offset >= realLength) {
1096            return offset;
1097        }
1098        Editable editable = getText();
1099        while (offset >= 0 && findText(editable, offset) == -1 && findChip(offset) == null) {
1100            // Keep walking backward!
1101            offset--;
1102        }
1103        return offset;
1104    }
1105
1106    private int findText(Editable text, int offset) {
1107        if (text.charAt(offset) != ' ') {
1108            return offset;
1109        }
1110        return -1;
1111    }
1112
1113    private RecipientChip findChip(int offset) {
1114        RecipientChip[] chips = getSpannable().getSpans(0, getText().length(), RecipientChip.class);
1115        // Find the chip that contains this offset.
1116        for (int i = 0; i < chips.length; i++) {
1117            RecipientChip chip = chips[i];
1118            int start = getChipStart(chip);
1119            int end = getChipEnd(chip);
1120            if (offset >= start && offset <= end) {
1121                return chip;
1122            }
1123        }
1124        return null;
1125    }
1126
1127    private CharSequence createChip(RecipientEntry entry, boolean pressed) {
1128        String displayText = entry.getDestination();
1129        displayText = (String) mTokenizer.terminateToken(displayText);
1130        // Always leave a blank space at the end of a chip.
1131        int textLength = displayText.length()-1;
1132        SpannableString chipText = new SpannableString(displayText);
1133        int end = getSelectionEnd();
1134        int start = mTokenizer.findTokenStart(getText(), end);
1135        try {
1136            RecipientChip chip = constructChipSpan(entry, start, pressed);
1137            chipText.setSpan(chip, 0, textLength,
1138                    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1139            chip.setOriginalText(chipText.toString());
1140        } catch (NullPointerException e) {
1141            Log.e(TAG, e.getMessage(), e);
1142            return null;
1143        }
1144
1145        return chipText;
1146    }
1147
1148    /**
1149     * When an item in the suggestions list has been clicked, create a chip from the
1150     * contact information of the selected item.
1151     */
1152    @Override
1153    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
1154        submitItemAtPosition(position);
1155    }
1156
1157    private void submitItemAtPosition(int position) {
1158        RecipientEntry entry = createValidatedEntry(
1159                (RecipientEntry)getAdapter().getItem(position));
1160        if (entry == null) {
1161            return;
1162        }
1163        clearComposingText();
1164
1165        int end = getSelectionEnd();
1166        int start = mTokenizer.findTokenStart(getText(), end);
1167
1168        Editable editable = getText();
1169        QwertyKeyListener.markAsReplaced(editable, start, end, "");
1170        CharSequence chip = createChip(entry, false);
1171        if (chip != null) {
1172            editable.replace(start, end, chip);
1173        }
1174    }
1175
1176    private RecipientEntry createValidatedEntry(RecipientEntry item) {
1177        if (item == null) {
1178            return null;
1179        }
1180        final RecipientEntry entry;
1181        // If the display name and the address are the same, or if this is a
1182        // valid contact, but the destination is invalid, then make this a fake
1183        // recipient that is editable.
1184        String destination = item.getDestination();
1185        if (TextUtils.isEmpty(item.getDisplayName())
1186                || TextUtils.equals(item.getDisplayName(), destination)
1187                || (mValidator != null && !mValidator.isValid(destination))) {
1188            entry = RecipientEntry.constructFakeEntry(destination);
1189        } else {
1190            entry = item;
1191        }
1192        return entry;
1193    }
1194
1195    /** Returns a collection of contact Id for each chip inside this View. */
1196    /* package */ Collection<Long> getContactIds() {
1197        final Set<Long> result = new HashSet<Long>();
1198        RecipientChip[] chips = getRecipients();
1199        if (chips != null) {
1200            for (RecipientChip chip : chips) {
1201                result.add(chip.getContactId());
1202            }
1203        }
1204        return result;
1205    }
1206
1207    private RecipientChip[] getRecipients() {
1208        return getSpannable().getSpans(0, getText().length(), RecipientChip.class);
1209    }
1210
1211    private RecipientChip[] getSortedRecipients() {
1212        ArrayList<RecipientChip> recipientsList = new ArrayList<RecipientChip>(Arrays
1213                .asList(getRecipients()));
1214        final Spannable spannable = getSpannable();
1215        Collections.sort(recipientsList, new Comparator<RecipientChip>() {
1216
1217            @Override
1218            public int compare(RecipientChip first, RecipientChip second) {
1219                int firstStart = spannable.getSpanStart(first);
1220                int secondStart = spannable.getSpanStart(second);
1221                if (firstStart < secondStart) {
1222                    return -1;
1223                } else if (firstStart > secondStart) {
1224                    return 1;
1225                } else {
1226                    return 0;
1227                }
1228            }
1229        });
1230        return recipientsList.toArray(new RecipientChip[recipientsList.size()]);
1231    }
1232
1233    /** Returns a collection of data Id for each chip inside this View. May be null. */
1234    /* package */ Collection<Long> getDataIds() {
1235        final Set<Long> result = new HashSet<Long>();
1236        RecipientChip [] chips = getRecipients();
1237        if (chips != null) {
1238            for (RecipientChip chip : chips) {
1239                result.add(chip.getDataId());
1240            }
1241        }
1242        return result;
1243    }
1244
1245
1246    @Override
1247    public boolean onActionItemClicked(ActionMode mode, MenuItem item) {
1248        return false;
1249    }
1250
1251    @Override
1252    public void onDestroyActionMode(ActionMode mode) {
1253    }
1254
1255    @Override
1256    public boolean onPrepareActionMode(ActionMode mode, Menu menu) {
1257        return false;
1258    }
1259
1260    /**
1261     * No chips are selectable.
1262     */
1263    @Override
1264    public boolean onCreateActionMode(ActionMode mode, Menu menu) {
1265        return false;
1266    }
1267
1268    /**
1269     * Create the more chip. The more chip is text that replaces any chips that
1270     * do not fit in the pre-defined available space when the
1271     * RecipientEditTextView loses focus.
1272     */
1273    private void createMoreChip() {
1274        if (!mShouldShrink) {
1275            return;
1276        }
1277
1278        ImageSpan[] tempMore = getSpannable().getSpans(0, getText().length(), MoreImageSpan.class);
1279        if (tempMore.length > 0) {
1280            getSpannable().removeSpan(tempMore[0]);
1281        }
1282        RecipientChip[] recipients = getSortedRecipients();
1283        if (recipients == null || recipients.length <= CHIP_LIMIT) {
1284            mMoreChip = null;
1285            return;
1286        }
1287        Spannable spannable = getSpannable();
1288        int numRecipients = recipients.length;
1289        int overage = numRecipients - CHIP_LIMIT;
1290        String moreText = String.format(mMoreItem.getText().toString(), overage);
1291        TextPaint morePaint = new TextPaint(getPaint());
1292        morePaint.setTextSize(mMoreItem.getTextSize());
1293        morePaint.setColor(mMoreItem.getCurrentTextColor());
1294        int width = (int)morePaint.measureText(moreText) + mMoreItem.getPaddingLeft()
1295                + mMoreItem.getPaddingRight();
1296        int height = getLineHeight();
1297        Bitmap drawable = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
1298        Canvas canvas = new Canvas(drawable);
1299        canvas.drawText(moreText, 0, moreText.length(), 0, height - getLayout().getLineDescent(0),
1300                morePaint);
1301
1302        Drawable result = new BitmapDrawable(getResources(), drawable);
1303        result.setBounds(0, 0, width, height);
1304        MoreImageSpan moreSpan = new MoreImageSpan(result);
1305        // Remove the overage chips.
1306        if (recipients == null || recipients.length == 0) {
1307            Log.w(TAG,
1308                    "We have recipients. Tt should not be possible to have zero RecipientChips.");
1309            mMoreChip = null;
1310            return;
1311        }
1312        mRemovedSpans = new ArrayList<RecipientChip>();
1313        int totalReplaceStart = 0;
1314        int totalReplaceEnd = 0;
1315        Editable text = getText();
1316        for (int i = numRecipients - overage; i < recipients.length; i++) {
1317            mRemovedSpans.add(recipients[i]);
1318            if (i == numRecipients - overage) {
1319                totalReplaceStart = spannable.getSpanStart(recipients[i]);
1320            }
1321            if (i == recipients.length - 1) {
1322                totalReplaceEnd = spannable.getSpanEnd(recipients[i]);
1323            }
1324            if (mTemporaryRecipients == null || !mTemporaryRecipients.contains(recipients[i])) {
1325                int spanStart = spannable.getSpanStart(recipients[i]);
1326                int spanEnd = spannable.getSpanEnd(recipients[i]);
1327                recipients[i].setOriginalText(text.toString().substring(spanStart, spanEnd));
1328            }
1329            spannable.removeSpan(recipients[i]);
1330        }
1331        int end = Math.max(totalReplaceStart, totalReplaceEnd);
1332        int start = Math.min(totalReplaceStart, totalReplaceEnd);
1333        SpannableString chipText = new SpannableString(text.subSequence(start, end));
1334        chipText.setSpan(moreSpan, 0, chipText.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1335        text.replace(start, end, chipText);
1336        mMoreChip = moreSpan;
1337    }
1338
1339    /**
1340     * Replace the more chip, if it exists, with all of the recipient chips it had
1341     * replaced when the RecipientEditTextView gains focus.
1342     */
1343    private void removeMoreChip() {
1344        if (mMoreChip != null) {
1345            Spannable span = getSpannable();
1346            span.removeSpan(mMoreChip);
1347            mMoreChip = null;
1348            // Re-add the spans that were removed.
1349            if (mRemovedSpans != null && mRemovedSpans.size() > 0) {
1350                // Recreate each removed span.
1351                Editable editable = getText();
1352                for (RecipientChip chip : mRemovedSpans) {
1353                    int chipStart;
1354                    int chipEnd;
1355                    String token;
1356                    // Need to find the location of the chip, again.
1357                    token = (String) chip.getOriginalText();
1358                    chipStart = editable.toString().indexOf(token);
1359                    // -1 for the space!
1360                    chipEnd = Math.min(editable.length(), chipStart + token.length());
1361                    // Only set the span if we found a matching token.
1362                    if (chipStart != -1) {
1363                        editable.setSpan(chip, chipStart, chipEnd,
1364                                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
1365                    }
1366                }
1367                mRemovedSpans.clear();
1368            }
1369        }
1370    }
1371
1372    /**
1373     * Show specified chip as selected. If the RecipientChip is just an email address,
1374     * selecting the chip will take the contents of the chip and place it at
1375     * the end of the RecipientEditTextView for inline editing. If the
1376     * RecipientChip is a complete contact, then selecting the chip
1377     * will change the background color of the chip, show the delete icon,
1378     * and a popup window with the address in use highlighted and any other
1379     * alternate addresses for the contact.
1380     * @param currentChip Chip to select.
1381     * @return A RecipientChip in the selected state or null if the chip
1382     * just contained an email address.
1383     */
1384    public RecipientChip selectChip(RecipientChip currentChip) {
1385        if (currentChip.getContactId() == RecipientEntry.INVALID_CONTACT) {
1386            CharSequence text = currentChip.getValue();
1387            Editable editable = getText();
1388            removeChip(currentChip);
1389            editable.append(text);
1390            setCursorVisible(true);
1391            setSelection(editable.length());
1392            return null;
1393        } else if (currentChip.getContactId() == RecipientEntry.GENERATED_CONTACT) {
1394            int start = getChipStart(currentChip);
1395            int end = getChipEnd(currentChip);
1396            getSpannable().removeSpan(currentChip);
1397            RecipientChip newChip;
1398            try {
1399                newChip = constructChipSpan(currentChip.getEntry(), start, true);
1400            } catch (NullPointerException e) {
1401                Log.e(TAG, e.getMessage(), e);
1402                return null;
1403            }
1404            Editable editable = getText();
1405            QwertyKeyListener.markAsReplaced(editable, start, end, "");
1406            if (start == -1 || end == -1) {
1407                Log.d(TAG, "The chip being selected no longer exists but should.");
1408            } else {
1409                editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1410            }
1411            newChip.setSelected(true);
1412            if (newChip.getEntry().getContactId() == RecipientEntry.INVALID_CONTACT) {
1413                scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip)));
1414            }
1415            showAddress(newChip, mAddressPopup, getWidth(), getContext());
1416            setCursorVisible(false);
1417            return newChip;
1418        } else {
1419            int start = getChipStart(currentChip);
1420            int end = getChipEnd(currentChip);
1421            getSpannable().removeSpan(currentChip);
1422            RecipientChip newChip;
1423            try {
1424                newChip = constructChipSpan(currentChip.getEntry(), start, true);
1425            } catch (NullPointerException e) {
1426                Log.e(TAG, e.getMessage(), e);
1427                return null;
1428            }
1429            Editable editable = getText();
1430            QwertyKeyListener.markAsReplaced(editable, start, end, "");
1431            if (start == -1 || end == -1) {
1432                Log.d(TAG, "The chip being selected no longer exists but should.");
1433            } else {
1434                editable.setSpan(newChip, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1435            }
1436            newChip.setSelected(true);
1437            if (newChip.getEntry().getContactId() == RecipientEntry.INVALID_CONTACT) {
1438                scrollLineIntoView(getLayout().getLineForOffset(getChipStart(newChip)));
1439            }
1440            showAlternates(newChip, mAlternatesPopup, getWidth(), getContext());
1441            setCursorVisible(false);
1442            return newChip;
1443        }
1444    }
1445
1446
1447    private void showAddress(final RecipientChip currentChip, final ListPopupWindow popup,
1448            int width, Context context) {
1449        int line = getLayout().getLineForOffset(getChipStart(currentChip));
1450        int bottom = calculateOffsetFromBottom(line);
1451        // Align the alternates popup with the left side of the View,
1452        // regardless of the position of the chip tapped.
1453        popup.setWidth(width);
1454        popup.setAnchorView(this);
1455        popup.setVerticalOffset(bottom);
1456        popup.setAdapter(createSingleAddressAdapter(currentChip));
1457        popup.setOnItemClickListener(new OnItemClickListener() {
1458            @Override
1459            public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
1460                unselectChip(currentChip);
1461                popup.dismiss();
1462            }
1463        });
1464        popup.show();
1465        ListView listView = popup.getListView();
1466        listView.setChoiceMode(ListView.CHOICE_MODE_SINGLE);
1467        listView.setItemChecked(0, true);
1468    }
1469
1470    /**
1471     * Remove selection from this chip. Unselecting a RecipientChip will render
1472     * the chip without a delete icon and with an unfocused background. This
1473     * is called when the RecipientChip no longer has focus.
1474     */
1475    public void unselectChip(RecipientChip chip) {
1476        int start = getChipStart(chip);
1477        int end = getChipEnd(chip);
1478        Editable editable = getText();
1479        mSelectedChip = null;
1480        if (start == -1 || end == -1) {
1481            Log.e(TAG, "The chip being unselected no longer exists but should.");
1482        } else {
1483            getSpannable().removeSpan(chip);
1484            QwertyKeyListener.markAsReplaced(editable, start, end, "");
1485            editable.removeSpan(chip);
1486            try {
1487                editable.setSpan(constructChipSpan(chip.getEntry(), start, false), start, end,
1488                        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1489            } catch (NullPointerException e) {
1490                Log.e(TAG, e.getMessage(), e);
1491            }
1492        }
1493        setCursorVisible(true);
1494        setSelection(editable.length());
1495        if (mAlternatesPopup != null && mAlternatesPopup.isShowing()) {
1496            mAlternatesPopup.dismiss();
1497        }
1498    }
1499
1500
1501    /**
1502     * Return whether this chip contains the position passed in.
1503     */
1504    public boolean matchesChip(RecipientChip chip, int offset) {
1505        int start = getChipStart(chip);
1506        int end = getChipEnd(chip);
1507        if (start == -1 || end == -1) {
1508            return false;
1509        }
1510        return (offset >= start && offset <= end);
1511    }
1512
1513
1514    /**
1515     * Return whether a touch event was inside the delete target of
1516     * a selected chip. It is in the delete target if:
1517     * 1) the x and y points of the event are within the
1518     * delete assset.
1519     * 2) the point tapped would have caused a cursor to appear
1520     * right after the selected chip.
1521     * @return boolean
1522     */
1523    private boolean isInDelete(RecipientChip chip, int offset, float x, float y) {
1524        // Figure out the bounds of this chip and whether or not
1525        // the user clicked in the X portion.
1526        return chip.isSelected() && offset == getChipEnd(chip);
1527    }
1528
1529    /**
1530     * Remove the chip and any text associated with it from the RecipientEditTextView.
1531     */
1532    private void removeChip(RecipientChip chip) {
1533        Spannable spannable = getSpannable();
1534        int spanStart = spannable.getSpanStart(chip);
1535        int spanEnd = spannable.getSpanEnd(chip);
1536        Editable text = getText();
1537        int toDelete = spanEnd;
1538        boolean wasSelected = chip == mSelectedChip;
1539        // Clear that there is a selected chip before updating any text.
1540        if (wasSelected) {
1541            mSelectedChip = null;
1542        }
1543        // Always remove trailing spaces when removing a chip.
1544        while (toDelete >= 0 && toDelete < text.length() && text.charAt(toDelete) == ' ') {
1545            toDelete++;
1546        }
1547        spannable.removeSpan(chip);
1548        text.delete(spanStart, toDelete);
1549        if (wasSelected) {
1550            clearSelectedChip();
1551        }
1552    }
1553
1554    /**
1555     * Replace this currently selected chip with a new chip
1556     * that uses the contact data provided.
1557     */
1558    public void replaceChip(RecipientChip chip, RecipientEntry entry) {
1559        boolean wasSelected = chip == mSelectedChip;
1560        if (wasSelected) {
1561            mSelectedChip = null;
1562        }
1563        int start = getChipStart(chip);
1564        int end = getChipEnd(chip);
1565        getSpannable().removeSpan(chip);
1566        Editable editable = getText();
1567        CharSequence chipText = createChip(entry, false);
1568        if (start == -1 || end == -1) {
1569            Log.e(TAG, "The chip to replace does not exist but should.");
1570            editable.insert(0, chipText);
1571        } else {
1572            // There may be a space to replace with this chip's new associated
1573            // space. Check for it.
1574            int toReplace = end;
1575            while (toReplace >= 0 && toReplace < editable.length()
1576                    && editable.charAt(toReplace) == ' ') {
1577                toReplace++;
1578            }
1579            editable.replace(start, toReplace, chipText);
1580        }
1581        setCursorVisible(true);
1582        if (wasSelected) {
1583            clearSelectedChip();
1584        }
1585    }
1586
1587    /**
1588     * Handle click events for a chip. When a selected chip receives a click
1589     * event, see if that event was in the delete icon. If so, delete it.
1590     * Otherwise, unselect the chip.
1591     */
1592    public void onClick(RecipientChip chip, int offset, float x, float y) {
1593        if (chip.isSelected()) {
1594            if (isInDelete(chip, offset, x, y)) {
1595                removeChip(chip);
1596            } else {
1597                clearSelectedChip();
1598            }
1599        }
1600    }
1601
1602    private boolean chipsPending() {
1603        return mPendingChipsCount > 0 || (mRemovedSpans != null && mRemovedSpans.size() > 0);
1604    }
1605
1606    @Override
1607    public void removeTextChangedListener(TextWatcher watcher) {
1608        mTextWatcher = null;
1609        super.removeTextChangedListener(watcher);
1610    }
1611
1612    private class RecipientTextWatcher implements TextWatcher {
1613        @Override
1614        public void afterTextChanged(Editable s) {
1615            // If the text has been set to null or empty, make sure we remove
1616            // all the spans we applied.
1617            if (TextUtils.isEmpty(s)) {
1618                // Remove all the chips spans.
1619                Spannable spannable = getSpannable();
1620                RecipientChip[] chips = spannable.getSpans(0, getText().length(),
1621                        RecipientChip.class);
1622                for (RecipientChip chip : chips) {
1623                    spannable.removeSpan(chip);
1624                }
1625                if (mMoreChip != null) {
1626                    spannable.removeSpan(mMoreChip);
1627                }
1628                return;
1629            }
1630            // Get whether there are any recipients pending addition to the
1631            // view. If there are, don't do anything in the text watcher.
1632            if (chipsPending()) {
1633                return;
1634            }
1635            if (mSelectedChip != null) {
1636                setCursorVisible(true);
1637                setSelection(getText().length());
1638                clearSelectedChip();
1639            }
1640            int length = s.length();
1641            // Make sure there is content there to parse and that it is
1642            // not just the commit character.
1643            if (length > 1) {
1644                char last;
1645                int end = getSelectionEnd() == 0 ? 0 : getSelectionEnd() - 1;
1646                int len = length() - 1;
1647                if (end != len) {
1648                    last = s.charAt(end);
1649                } else {
1650                    last = s.charAt(len);
1651                }
1652                if (last == COMMIT_CHAR_SEMICOLON || last == COMMIT_CHAR_COMMA) {
1653                    commitByCharacter();
1654                } else if (last == COMMIT_CHAR_SPACE) {
1655                    // Check if this is a valid email address. If it is,
1656                    // commit it.
1657                    String text = getText().toString();
1658                    int tokenStart = mTokenizer.findTokenStart(text, getSelectionEnd());
1659                    String sub = text.substring(tokenStart, mTokenizer.findTokenEnd(text,
1660                            tokenStart));
1661                    if (mValidator != null && mValidator.isValid(sub)) {
1662                        commitByCharacter();
1663                    }
1664                }
1665            }
1666        }
1667
1668        @Override
1669        public void onTextChanged(CharSequence s, int start, int before, int count) {
1670            // Do nothing.
1671        }
1672
1673        @Override
1674        public void beforeTextChanged(CharSequence s, int start, int count, int after) {
1675        }
1676    }
1677
1678    private class RecipientReplacementTask extends AsyncTask<Void, Void, Void> {
1679        private RecipientChip createFreeChip(RecipientEntry entry) {
1680            String displayText = entry.getDestination();
1681            if (displayText.indexOf(",") == -1) {
1682                displayText = (String) mTokenizer.terminateToken(displayText);
1683            }
1684            try {
1685                return constructChipSpan(entry, -1, false);
1686            } catch (NullPointerException e) {
1687                Log.e(TAG, e.getMessage(), e);
1688                return null;
1689            }
1690        }
1691
1692        @Override
1693        protected Void doInBackground(Void... params) {
1694            if (mIndividualReplacements != null) {
1695                mIndividualReplacements.cancel(true);
1696            }
1697            // For each chip in the list, look up the matching contact.
1698            // If there is a match, replace that chip with the matching
1699            // chip.
1700            final ArrayList<RecipientChip> originalRecipients = new ArrayList<RecipientChip>();
1701            RecipientChip[] existingChips = getSortedRecipients();
1702            for (int i = 0; i < existingChips.length; i++) {
1703                originalRecipients.add(existingChips[i]);
1704            }
1705            if (mRemovedSpans != null) {
1706                originalRecipients.addAll(mRemovedSpans);
1707            }
1708            String[] addresses = new String[originalRecipients.size()];
1709            for (int i = 0; i < originalRecipients.size(); i++) {
1710                addresses[i] = originalRecipients.get(i).getEntry().getDestination();
1711            }
1712            HashMap<String, RecipientEntry> entries = RecipientAlternatesAdapter
1713                    .getMatchingRecipients(getContext(), addresses);
1714            final ArrayList<RecipientChip> replacements = new ArrayList<RecipientChip>();
1715            for (final RecipientChip temp : originalRecipients) {
1716                RecipientEntry entry = null;
1717                if (RecipientEntry.isCreatedRecipient(temp.getEntry().getContactId())
1718                        && getSpannable().getSpanStart(temp) != -1) {
1719                    // Replace this.
1720                    entry = createValidatedEntry(entries.get(tokenizeAddress(temp.getEntry()
1721                            .getDestination())));
1722                }
1723                if (entry != null) {
1724                    replacements.add(createFreeChip(entry));
1725                } else {
1726                    replacements.add(temp);
1727                }
1728            }
1729            if (replacements != null && replacements.size() > 0) {
1730                mHandler.post(new Runnable() {
1731                    @Override
1732                    public void run() {
1733                        SpannableStringBuilder text = new SpannableStringBuilder(getText()
1734                                .toString());
1735                        Editable oldText = getText();
1736                        int start, end;
1737                        int i = 0;
1738                        for (RecipientChip chip : originalRecipients) {
1739                            start = oldText.getSpanStart(chip);
1740                            if (start != -1) {
1741                                end = oldText.getSpanEnd(chip);
1742                                text.removeSpan(chip);
1743                                // Leave a spot for the space!
1744                                RecipientChip replacement = replacements.get(i);
1745                                text.setSpan(replacement, start, end,
1746                                        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
1747                                replacement.setOriginalText(text.toString().substring(start, end));
1748                            }
1749                            i++;
1750                        }
1751                        Editable editable = getText();
1752                        editable.clear();
1753                        editable.insert(0, text);
1754                        originalRecipients.clear();
1755                    }
1756                });
1757            }
1758            return null;
1759        }
1760    }
1761
1762    private class IndividualReplacementTask extends AsyncTask<Object, Void, Void> {
1763        @SuppressWarnings("unchecked")
1764        @Override
1765        protected Void doInBackground(Object... params) {
1766            // For each chip in the list, look up the matching contact.
1767            // If there is a match, replace that chip with the matching
1768            // chip.
1769            final ArrayList<RecipientChip> originalRecipients =
1770                (ArrayList<RecipientChip>) params[0];
1771            String[] addresses = new String[originalRecipients.size()];
1772            for (int i = 0; i < originalRecipients.size(); i++) {
1773                addresses[i] = originalRecipients.get(i).getEntry().getDestination();
1774            }
1775            HashMap<String, RecipientEntry> entries = RecipientAlternatesAdapter
1776                    .getMatchingRecipients(getContext(), addresses);
1777            for (final RecipientChip temp : originalRecipients) {
1778                if (RecipientEntry.isCreatedRecipient(temp.getEntry().getContactId())
1779                        && getSpannable().getSpanStart(temp) != -1) {
1780                    // Replace this.
1781                    final RecipientEntry entry = createValidatedEntry(entries
1782                            .get(tokenizeAddress(temp.getEntry().getDestination())));
1783                    if (entry != null) {
1784                        mHandler.post(new Runnable() {
1785                            @Override
1786                            public void run() {
1787                                replaceChip(temp, entry);
1788                            }
1789                        });
1790                    }
1791                }
1792            }
1793            return null;
1794        }
1795    }
1796
1797
1798    /**
1799     * MoreImageSpan is a simple class created for tracking the existence of a
1800     * more chip across activity restarts/
1801     */
1802    private class MoreImageSpan extends ImageSpan {
1803        public MoreImageSpan(Drawable b) {
1804            super(b);
1805        }
1806    }
1807
1808    @Override
1809    public boolean onDown(MotionEvent e) {
1810        return false;
1811    }
1812
1813    @Override
1814    public boolean onFling(MotionEvent e1, MotionEvent e2, float velocityX, float velocityY) {
1815        // Do nothing.
1816        return false;
1817    }
1818
1819    @Override
1820    public void onLongPress(MotionEvent event) {
1821        if (mSelectedChip != null) {
1822            return;
1823        }
1824        float x = event.getX();
1825        float y = event.getY();
1826        int offset = putOffsetInRange(getOffsetForPosition(x, y));
1827        RecipientChip currentChip = findChip(offset);
1828        if (currentChip != null) {
1829            // Copy the selected chip email address.
1830            showCopyDialog(currentChip.getEntry().getDestination());
1831        }
1832    }
1833
1834    private void showCopyDialog(final String address) {
1835        mCopyAddress = address;
1836        mCopyDialog = new Dialog(getContext());
1837        mCopyDialog.setTitle(address);
1838        mCopyDialog.setContentView(mCopyViewRes);
1839        mCopyDialog.setCancelable(true);
1840        mCopyDialog.setCanceledOnTouchOutside(true);
1841        mCopyDialog.findViewById(android.R.id.button1).setOnClickListener(this);
1842        mCopyDialog.setOnDismissListener(this);
1843        mCopyDialog.show();
1844    }
1845
1846    @Override
1847    public boolean onScroll(MotionEvent e1, MotionEvent e2, float distanceX, float distanceY) {
1848        // Do nothing.
1849        return false;
1850    }
1851
1852    @Override
1853    public void onShowPress(MotionEvent e) {
1854        // Do nothing.
1855    }
1856
1857    @Override
1858    public boolean onSingleTapUp(MotionEvent e) {
1859        // Do nothing.
1860        return false;
1861    }
1862
1863    @Override
1864    public void onDismiss(DialogInterface dialog) {
1865        mCopyAddress = null;
1866        mCopyDialog = null;
1867    }
1868
1869    @Override
1870    public void onClick(View v) {
1871        // Copy this to the clipboard.
1872        ClipboardManager clipboard = (ClipboardManager) getContext().getSystemService(
1873                Context.CLIPBOARD_SERVICE);
1874        clipboard.setPrimaryClip(ClipData.newPlainText("", mCopyAddress));
1875        mCopyDialog.dismiss();
1876    }
1877}
1878