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