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