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