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