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