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