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