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