Initial commit for chips UI library.

- create another directory for Chips UI.
- implement auto-complete list with photos
- introduce a stub EditText for chips implementation
-- RecipientEditText will be replaced shortly

BaseRecipientAdapter is based on CompositeCursorAdapter in
android-common library, but doesn't inherit it as they have
different assumptions (BaseRecipientAdapter doesn't rely on partition
but merge Directoryies' results).

Bug: 4443828
Change-Id: I74f48e73e44785edcc898690952a68b046ef5e0f
diff --git a/Android.mk b/Android.mk
new file mode 100644
index 0000000..f5a935d
--- /dev/null
+++ b/Android.mk
@@ -0,0 +1,23 @@
+# Copyright (C) 2011 The Android Open Source Project
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+LOCAL_PATH := $(call my-dir)
+
+include $(CLEAR_VARS)
+LOCAL_MODULE := android-common-chips
+LOCAL_SDK_VERSION := current
+LOCAL_SRC_FILES := \
+     $(call all-java-files-under, java) \
+     $(call all-logtags-files-under, java)
+include $(BUILD_STATIC_JAVA_LIBRARY)
diff --git a/java/com/android/ex/chips/BaseRecipientAdapter.java b/java/com/android/ex/chips/BaseRecipientAdapter.java
new file mode 100644
index 0000000..a362531
--- /dev/null
+++ b/java/com/android/ex/chips/BaseRecipientAdapter.java
@@ -0,0 +1,589 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ex.chips;
+
+import android.accounts.Account;
+import android.content.ContentResolver;
+import android.content.Context;
+import android.content.pm.PackageManager;
+import android.content.pm.PackageManager.NameNotFoundException;
+import android.content.res.Resources;
+import android.database.Cursor;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.net.Uri;
+import android.os.Handler;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.CommonDataKinds.Email;
+import android.provider.ContactsContract.Contacts;
+import android.provider.ContactsContract.Directory;
+import android.text.TextUtils;
+import android.text.util.Rfc822Token;
+import android.util.Log;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AutoCompleteTextView;
+import android.widget.BaseAdapter;
+import android.widget.Filter;
+import android.widget.Filterable;
+import android.widget.ImageView;
+import android.widget.TextView;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Adapter for showing a recipient list.
+ */
+public abstract class BaseRecipientAdapter extends BaseAdapter implements Filterable {
+    private static final String TAG = "BaseRecipientAdapter";
+
+    /**
+     * The preferred number of results to be retrieved. This number may be
+     * exceeded if there are several directories configured, because we will use
+     * the same limit for all directories.
+     */
+    private static final int DEFAULT_PREFERRED_MAX_RESULT_COUNT = 10;
+
+    /**
+     * The number of extra entries requested to allow for duplicates. Duplicates
+     * are removed from the overall result.
+     */
+    private static final int ALLOWANCE_FOR_DUPLICATES = 5;
+
+    /**
+     * Model object for a {@link Directory} row.
+     */
+    public final static class DirectorySearchParams {
+        public long directoryId;
+        public String directoryType;
+        public String displayName;
+        public String accountName;
+        public String accountType;
+        public CharSequence constraint;
+        public DirectoryFilter filter;
+    }
+
+    private static class EmailQuery {
+        public static final String[] PROJECTION = {
+            Contacts.DISPLAY_NAME,       // 0
+            Email.DATA,                  // 1
+            Email.CONTACT_ID,            // 2
+            Contacts.PHOTO_THUMBNAIL_URI // 3
+        };
+
+        public static final int NAME = 0;
+        public static final int ADDRESS = 1;
+        public static final int CONTACT_ID = 2;
+        public static final int PHOTO_THUMBNAIL_URI = 3;
+    }
+
+    // TODO: PhoneQuery
+
+    private static class DirectoryListQuery {
+
+        public static final Uri URI =
+                Uri.withAppendedPath(ContactsContract.AUTHORITY_URI, "directories");
+        public static final String[] PROJECTION = {
+            Directory._ID,              // 0
+            Directory.ACCOUNT_NAME,     // 1
+            Directory.ACCOUNT_TYPE,     // 2
+            Directory.DISPLAY_NAME,     // 3
+            Directory.PACKAGE_NAME,     // 4
+            Directory.TYPE_RESOURCE_ID, // 5
+        };
+
+        public static final int ID = 0;
+        public static final int ACCOUNT_NAME = 1;
+        public static final int ACCOUNT_TYPE = 2;
+        public static final int DISPLAY_NAME = 3;
+        public static final int PACKAGE_NAME = 4;
+        public static final int TYPE_RESOURCE_ID = 5;
+    }
+
+    /**
+     * An asynchronous filter used for loading two data sets: email rows from the local
+     * contact provider and the list of {@link Directory}'s.
+     */
+    private final class DefaultFilter extends Filter {
+
+        @Override
+        protected FilterResults performFiltering(CharSequence constraint) {
+            FilterResults results = new FilterResults();
+            Cursor cursor = null;
+            if (!TextUtils.isEmpty(constraint)) {
+                Uri uri = Email.CONTENT_FILTER_URI.buildUpon()
+                        .appendPath(constraint.toString())
+                        .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+                                String.valueOf(mPreferredMaxResultCount))
+                        .build();
+                cursor = mContentResolver.query(uri, EmailQuery.PROJECTION, null, null, null);
+                if (cursor != null) {
+                    results.count = cursor.getCount();
+                }
+            }
+
+            // TODO: implement group feature
+
+            final Cursor directoryCursor = mContentResolver.query(
+                    DirectoryListQuery.URI, DirectoryListQuery.PROJECTION, null, null, null);
+
+            results.values = new Cursor[] { directoryCursor, cursor };
+            return results;
+        }
+
+        @Override
+        protected void publishResults(final CharSequence constraint, FilterResults results) {
+            if (results.values != null) {
+                final Cursor[] cursors = (Cursor[]) results.values;
+                // Run on one thread.
+                mHandler.post(new Runnable() {
+                    @Override
+                    public void run() {
+                        onFirstDirectoryLoadFinished(constraint, cursors[0], cursors[1]);
+                    }
+                });
+            }
+            results.count = getCount();
+        }
+
+        @Override
+        public CharSequence convertResultToString(Object resultValue) {
+            final RecipientListEntry entry = (RecipientListEntry)resultValue;
+            final String displayName = entry.getDisplayName();
+            final String emailAddress = entry.getDestination();
+            if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
+                 return emailAddress;
+            } else {
+                return new Rfc822Token(displayName, emailAddress, null).toString();
+            }
+        }
+    }
+
+    /**
+     * An asynchronous filter that performs search in a particular directory.
+     */
+    private final class DirectoryFilter extends Filter {
+        private final int mDirectoryIndex;
+        private final long mDirectoryId;
+        private int mLimit;
+
+        public DirectoryFilter(int directoryIndex, long directoryId) {
+            this.mDirectoryIndex = directoryIndex;
+            this.mDirectoryId = directoryId;
+        }
+
+        public synchronized void setLimit(int limit) {
+            this.mLimit = limit;
+        }
+
+        public synchronized int getLimit() {
+            return this.mLimit;
+        }
+
+        @Override
+        protected FilterResults performFiltering(CharSequence constraint) {
+            FilterResults results = new FilterResults();
+            if (!TextUtils.isEmpty(constraint)) {
+                Uri uri = Email.CONTENT_FILTER_URI.buildUpon()
+                        .appendPath(constraint.toString())
+                        .appendQueryParameter(ContactsContract.DIRECTORY_PARAM_KEY,
+                                String.valueOf(mDirectoryId))
+                        .appendQueryParameter(ContactsContract.LIMIT_PARAM_KEY,
+                                String.valueOf(getLimit() + ALLOWANCE_FOR_DUPLICATES))
+                        .build();
+                Cursor cursor = mContentResolver.query(
+                        uri, EmailQuery.PROJECTION, null, null, null);
+                results.values = cursor;
+            }
+
+            // TODO: implement group feature
+
+            return results;
+        }
+
+        @Override
+        protected void publishResults(final CharSequence constraint, FilterResults results) {
+            final Cursor cursor = (Cursor) results.values;
+            mHandler.post(new Runnable() {
+                @Override
+                public void run() {
+                    onDirectoryLoadFinished(constraint, mDirectoryIndex, cursor);
+                }
+            });
+            results.count = getCount();
+        }
+    }
+
+    private Context mContext;
+    private final ContentResolver mContentResolver;
+    private Account mAccount;
+    private int mPreferredMaxResultCount;
+    private final Handler mHandler = new Handler();
+
+    /**
+     * Each destination (an email address or a phone number) is first inserted into mEntryMap and
+     * sorted. Duplicates are removed there. After that all the elems inside mEntryMap are copied
+     * to mEntry, which will be used to find items in this Adapter.
+     */
+    private LinkedHashMap<Integer, List<RecipientListEntry>> mEntryMap;
+    private List<RecipientListEntry> mEntries;
+
+    private List<DirectorySearchParams> mDirectorySearchParams;
+
+    public BaseRecipientAdapter(Context context) {
+        this(context, DEFAULT_PREFERRED_MAX_RESULT_COUNT);
+    }
+
+    public BaseRecipientAdapter(Context context, int preferredMaxResultCount) {
+        mContext = context;
+        mContentResolver = context.getContentResolver();
+        mPreferredMaxResultCount = preferredMaxResultCount;
+        mEntryMap = new LinkedHashMap<Integer, List<RecipientListEntry>>();
+        mDirectorySearchParams = new ArrayList<DirectorySearchParams>();
+    }
+
+    /**
+     * Set the account when known. Causes the search to prioritize contacts from that account.
+     */
+    public void setAccount(Account account) {
+        mAccount = account;
+    }
+
+    /** Will be called from {@link AutoCompleteTextView} to prepare auto-complete list. */
+    @Override
+    public Filter getFilter() {
+        return new DefaultFilter();
+    }
+
+    /**
+     * Handles the result of the initial call, which brings back the list of directories as well
+     * as the search results for the local directories.
+     */
+    protected void onFirstDirectoryLoadFinished(
+            CharSequence constraint, Cursor directoryCursor, Cursor defaultDirectoryCursor) {
+        try {
+            if (directoryCursor != null) {
+                setupOtherDirectories(directoryCursor);
+            }
+
+            int limit = 0;
+
+            if (defaultDirectoryCursor != null && defaultDirectoryCursor.getCount() > 0) {
+                final int defaultDirectoryCount = defaultDirectoryCursor.getCount();
+                mEntryMap.clear();
+                putEntriesWithCursor(defaultDirectoryCursor);
+                constructEntryList();
+                limit = mPreferredMaxResultCount - getCount();
+            }
+
+            int count = mDirectorySearchParams.size();
+            if (limit > 0) {
+                searchOtherDirectories(constraint, limit);
+            }
+        } finally {
+            if (directoryCursor != null) {
+                directoryCursor.close();
+            }
+            if (defaultDirectoryCursor != null) {
+                defaultDirectoryCursor.close();
+            }
+        }
+    }
+
+    private void setupOtherDirectories(Cursor directoryCursor) {
+        final PackageManager packageManager = mContext.getPackageManager();
+        final List<DirectorySearchParams> directories = new ArrayList<DirectorySearchParams>();
+        DirectorySearchParams preferredDirectory = null;
+        while (directoryCursor.moveToNext()) {
+            final long id = directoryCursor.getLong(DirectoryListQuery.ID);
+
+            // Skip the local invisible directory, because the default directory already includes
+            // all local results.
+            if (id == Directory.LOCAL_INVISIBLE) {
+                continue;
+            }
+
+            final DirectorySearchParams params = new DirectorySearchParams();
+            final String packageName = directoryCursor.getString(DirectoryListQuery.PACKAGE_NAME);
+            final int resourceId = directoryCursor.getInt(DirectoryListQuery.TYPE_RESOURCE_ID);
+            params.directoryId = id;
+            params.displayName = directoryCursor.getString(DirectoryListQuery.DISPLAY_NAME);
+            params.accountName = directoryCursor.getString(DirectoryListQuery.ACCOUNT_NAME);
+            params.accountType = directoryCursor.getString(DirectoryListQuery.ACCOUNT_TYPE);
+            if (packageName != null && resourceId != 0) {
+                try {
+                    final Resources resources =
+                            packageManager.getResourcesForApplication(packageName);
+                    params.directoryType = resources.getString(resourceId);
+                    if (params.directoryType == null) {
+                        Log.e(TAG, "Cannot resolve directory name: "
+                                + resourceId + "@" + packageName);
+                    }
+                } catch (NameNotFoundException e) {
+                    Log.e(TAG, "Cannot resolve directory name: "
+                            + resourceId + "@" + packageName, e);
+                }
+            }
+
+            // If an account has been provided and we found a directory that
+            // corresponds to that account, place that directory second, directly
+            // underneath the local contacts.
+            if (mAccount != null && mAccount.name.equals(params.accountName) &&
+                    mAccount.type.equals(params.accountType)) {
+                preferredDirectory = params;
+            } else {
+                directories.add(params);
+            }
+        }
+
+        if (preferredDirectory != null) {
+            directories.add(1, preferredDirectory);
+        }
+
+        for (DirectorySearchParams partition : directories) {
+            mDirectorySearchParams.add(partition);
+        }
+    }
+
+    /**
+     * Starts search in other directories
+     */
+    private void searchOtherDirectories(CharSequence constraint, int limit) {
+        final int count = mDirectorySearchParams.size();
+        // Note: skipping the default partition (index 0), which has already been loaded
+        for (int i = 1; i < count; i++) {
+            final DirectorySearchParams partition = mDirectorySearchParams.get(i);
+            partition.constraint = constraint;
+            if (partition.filter == null) {
+                partition.filter = new DirectoryFilter(i, partition.directoryId);
+            }
+            partition.filter.setLimit(limit);
+            partition.filter.filter(constraint);
+        }
+    }
+
+    /**
+     * Stores each contact information to {@link #mEntryMap}. {@link #mEntries} isn't touched here.
+     *
+     * In order to make the new information available from outside Adapter,
+     * call {@link #constructEntryList()} after this method.
+     */
+    private void putEntriesWithCursor(Cursor cursor) {
+        cursor.move(-1);
+        while (cursor.moveToNext()) {
+            final String displayName = cursor.getString(EmailQuery.NAME);
+            final String emailAddress = cursor.getString(EmailQuery.ADDRESS);
+            final int contactId = cursor.getInt(EmailQuery.CONTACT_ID);
+            final String photoThumbnailUri = cursor.getString(EmailQuery.PHOTO_THUMBNAIL_URI);
+
+            if (mEntryMap.containsKey(contactId)) {
+                // We already have a section for the person.
+                final List<RecipientListEntry> entryList = mEntryMap.get(contactId);
+                boolean isDuplicate = false;
+                for (RecipientListEntry entry : entryList) {
+                    String registeredAddress = entry.getDestination();
+                    if (TextUtils.equals(registeredAddress, emailAddress)) {
+                        isDuplicate = true;
+                        break;
+                    }
+                }
+                if (!isDuplicate) {
+                    entryList.add(RecipientListEntry.constructSecondLevelEntry(
+                            displayName, emailAddress, contactId));
+                }
+            } else {
+                byte[] photoBytes = null;
+                if (photoThumbnailUri != null) {
+                    // TODO: async
+                    final Cursor photoCursor = mContentResolver.query(
+                            Uri.parse(photoThumbnailUri),
+                            new String[] {
+                                Contacts.Photo.PHOTO
+                            }, null, null, null);
+                    if (photoCursor != null) {
+                        try {
+                            if (photoCursor.moveToFirst()) {
+                                photoBytes = photoCursor.getBlob(0);
+                            }
+                        } finally {
+                            photoCursor.close();
+                        }
+                    }
+                }
+
+                final List<RecipientListEntry> entryList = new ArrayList<RecipientListEntry>();
+                entryList.add(RecipientListEntry.constructTopLevelEntry(
+                        displayName, emailAddress, contactId, photoBytes));
+                mEntryMap.put(contactId, entryList);
+            }
+        }
+    }
+
+    /**
+     * Constructs an actual list for this Adapter using {@link #mEntryMap}.
+     */
+    private void constructEntryList() {
+        mEntries = new ArrayList<RecipientListEntry>();
+        for (Map.Entry<Integer, List<RecipientListEntry>> mapEntry : mEntryMap.entrySet()) {
+            final List<RecipientListEntry> entryList = mapEntry.getValue();
+            final int size = entryList.size();
+            for (int i = 0; i < size; i++) {
+                RecipientListEntry entry = entryList.get(i);
+                mEntries.add(entry);
+                if (i < size - 1) {
+                    mEntries.add(RecipientListEntry.SEP_WITHIN_GROUP);
+                }
+            }
+            mEntries.add(RecipientListEntry.SEP_NORMAL);
+        }
+        if (mEntries.size() > 1) {
+            mEntries.remove(mEntries.size() - 1);
+        }
+
+        notifyDataSetChanged();
+    }
+
+    public void onDirectoryLoadFinished(
+            CharSequence constraint, int partitionIndex, Cursor cursor) {
+        if (cursor != null) {
+            try {
+                if (partitionIndex < mDirectorySearchParams.size()) {
+                    final DirectorySearchParams params =
+                            mDirectorySearchParams.get(partitionIndex);
+
+                    // Check if the received result matches the current constraint
+                    // If not - the user must have continued typing after the request was issued
+                    if (TextUtils.equals(constraint, params.constraint)) {
+                        putEntriesWithCursor(cursor);
+                        constructEntryList();
+                    }
+                }
+            } finally {
+                cursor.close();
+            }
+        }
+    }
+
+    public void close() {
+    }
+
+    @Override
+    public int getCount() {
+        return mEntries != null ? mEntries.size() : 0;
+    }
+
+    @Override
+    public Object getItem(int position) {
+        return mEntries != null ? mEntries.get(position) : null;
+    }
+
+    @Override
+    public long getItemId(int position) {
+        return position;
+    }
+
+    @Override
+    public View getView(int position, View convertView, ViewGroup parent) {
+        if (mEntries == null) {
+            return null;
+        }
+
+        final RecipientListEntry entry = mEntries.get(position);
+        if (entry.isSeparator()) {
+            if (entry == RecipientListEntry.SEP_NORMAL) {
+                return inflateSeparatorView(parent);
+            } else if (entry == RecipientListEntry.SEP_WITHIN_GROUP) {
+                return inflateSeparatorViewWithinGroup(parent);
+            } else {
+                Log.e(TAG, "Unknown divider type.");
+                return null;
+            }
+        } else {
+            String displayName = entry.getDisplayName();
+            String emailAddress = entry.getDestination();
+            if (TextUtils.isEmpty(displayName) || TextUtils.equals(displayName, emailAddress)) {
+                displayName = emailAddress;
+                emailAddress = null;
+            }
+
+            final View itemView = inflateItemView(parent);
+            final TextView displayNameView = getDisplayNameView(itemView);
+            final TextView emailAddressView = getDestinationView(itemView);
+            final ImageView imageView = getPhotoView(itemView);
+            final View photoContainerView = getPhotoContainerView(itemView);
+            displayNameView.setText(displayName);
+            if (!TextUtils.isEmpty(emailAddress)) {
+                emailAddressView.setText(emailAddress);
+            }
+            if (imageView != null) {
+                if (entry.isFirstLevel()) {
+                    final byte[] photoBytes = entry.getPhotoBytes();
+                    if (photoBytes != null && imageView != null) {
+                        Bitmap photo = BitmapFactory.decodeByteArray(
+                                photoBytes, 0, photoBytes.length);
+                        imageView.setImageBitmap(photo);
+                    } else {
+                        imageView.setImageResource(getDefaultPhotoResource());
+                    }
+                } else {
+                    displayNameView.setVisibility(View.GONE);
+                    if (photoContainerView != null) {
+                        photoContainerView.setVisibility(View.GONE);
+                    }
+                }
+            }
+            return itemView;
+        }
+    }
+
+    /**
+     * Inflates a View for each item inside auto-complete list. Subclasses must return the View
+     * containing two TextViews (for display name and destination) and one ImageView (for photo).
+     * The photo View should be surrounded by container (like FrameLayout)
+     * @see #getDisplayNameView(View)
+     * @see #getDestinationView(View)
+     * @see #getPhotoView(View)
+     * @see #getPhotoContainerView(View)
+     */
+    protected abstract View inflateItemView(ViewGroup parent);
+    /** Inflates a View for a separator dividing two person or groups. */
+    protected abstract View inflateSeparatorView(ViewGroup parent);
+    /** Inflates a View for a separator dividing two destinations for a same person or group. */
+    protected abstract View inflateSeparatorViewWithinGroup(ViewGroup parent);
+
+    /** Returns TextView in itemView for showing a display name. */
+    protected abstract TextView getDisplayNameView(View itemView);
+    /**
+     * Returns TextView in itemView for showing a destination (an email address or a phone number).
+     */
+    protected abstract TextView getDestinationView(View itemView);
+    /** Returns ImageView in itemView for showing photo image for a person. */
+    protected abstract ImageView getPhotoView(View itemView);
+    /**
+     * Returns a View containing ImageView given by {@link #getPhotoView(View)}. Can be null.
+     */
+    protected abstract View getPhotoContainerView(View itemView);
+
+    /**
+     * Returns a resource ID representing an image which should be shown when ther's no relevant
+     * photo is available.
+     */
+    protected abstract int getDefaultPhotoResource();
+}
diff --git a/java/com/android/ex/chips/RecipientEditText.java b/java/com/android/ex/chips/RecipientEditText.java
new file mode 100644
index 0000000..26b0bb1
--- /dev/null
+++ b/java/com/android/ex/chips/RecipientEditText.java
@@ -0,0 +1,194 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ex.chips;
+
+import android.content.Context;
+import android.content.res.Resources;
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.drawable.BitmapDrawable;
+import android.graphics.drawable.Drawable;
+import android.text.Layout;
+import android.text.Spannable;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextPaint;
+import android.text.TextUtils;
+import android.text.style.ImageSpan;
+import android.text.util.Rfc822Token;
+import android.util.AttributeSet;
+import android.util.Log;
+import android.view.MotionEvent;
+import android.widget.MultiAutoCompleteTextView;
+import android.widget.TextView;
+
+public class RecipientEditText extends MultiAutoCompleteTextView {
+    private static final String TAG = "RecipientEditText";
+
+    public RecipientEditText(Context context) {
+        super(context);
+    }
+
+    public RecipientEditText(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public RecipientEditText(Context context, AttributeSet attrs, int defStyle) {
+        super(context, attrs, defStyle);
+    }
+
+    @Override
+    public boolean onTouchEvent(MotionEvent event) {
+        boolean superResult = super.onTouchEvent(event);
+        final int action = event.getActionMasked();
+        int offset = getOffset((int)event.getX(), (int)event.getY());
+        int lineTop = getLayout().getLineTop(getLineAtCoordinate((int)event.getY()));
+        int lineBottom = getLayout().getLineBottom(getLineAtCoordinate((int)event.getY()));
+
+        CharSequence text = getText();
+        if ((text instanceof Spannable)) {
+            Spannable spannable = (Spannable) text;
+            ChipSpan[] chips = spannable.getSpans(offset, offset, ChipSpan.class);
+            int chipsCount = chips.length;
+            if (chipsCount > 0) {
+                if (chipsCount > 1) {
+                    Log.d(TAG, "chips too many: " + chipsCount);
+                }
+                ChipSpan chip = chips[0];
+
+                int spanStart = spannable.getSpanStart(chip);
+                int spanEnd = spannable.getSpanEnd(chip);
+                CharSequence chipText = chip.getText();
+                spannable.removeSpan(chip);
+
+                TextPaint paint = getPaint();
+                int width = (int) Math.floor(paint.measureText(text, 0, text.length()));
+                int height = lineBottom - lineTop;
+                float ascent = getLayout().getLineAscent(getLineAtCoordinate((int)event.getY()));
+
+                if (action == MotionEvent.ACTION_DOWN) {
+                    spannable.setSpan(constructChipSpan(this, chipText, true),
+                            spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+                } else {
+                    spannable.setSpan(constructChipSpan(this, chipText, false),
+                            spanStart, spanEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+                }
+                setCursorVisible(false);
+            } else {
+                setCursorVisible(true);
+            }
+        }
+        return superResult;
+    }
+
+    /* copied from TextView. TextView#getOffset() is hidden */
+
+    public int getOffset(int x, int y) {
+        if (getLayout() == null) return -1;
+        final int line = getLineAtCoordinate(y);
+        final int offset = getOffsetAtCoordinate(line, x);
+        return offset;
+    }
+
+    private int convertToLocalHorizontalCoordinate(int x) {
+        x -= getTotalPaddingLeft();
+        // Clamp the position to inside of the view.
+        x = Math.max(0, x);
+        x = Math.min(getWidth() - getTotalPaddingRight() - 1, x);
+        x += getScrollX();
+        return x;
+    }
+
+    private int getLineAtCoordinate(int y) {
+        y -= getTotalPaddingTop();
+        // Clamp the position to inside of the view.
+        y = Math.max(0, y);
+        y = Math.min(getHeight() - getTotalPaddingBottom() - 1, y);
+        y += getScrollY();
+        return getLayout().getLineForVertical(y);
+    }
+
+    private int getOffsetAtCoordinate(int line, int x) {
+        x = convertToLocalHorizontalCoordinate(x);
+        return getLayout().getOffsetForHorizontal(line, x);
+    }
+
+    /* end of copied from TextView */
+
+    @Override
+    protected CharSequence convertSelectionToString(Object selectedItem) {
+        final RecipientListEntry entry = (RecipientListEntry)selectedItem;
+        final String displayName = entry.getDisplayName();
+        final String email = entry.getDestination();
+        if (TextUtils.isEmpty(displayName) && TextUtils.isEmpty(email)) {
+            Log.w(TAG, "Both a display name and an email are null");
+            return null;
+        } else {
+            final Rfc822Token token = new Rfc822Token(displayName, email, null);
+            final CharSequence underlyingText = token.toString() + ", ";
+            final CharSequence displayText =
+                    !TextUtils.isEmpty(displayName) ? displayName : email;
+            SpannableStringBuilder builder = new SpannableStringBuilder(underlyingText);
+            builder.setSpan(constructChipSpan(this, displayText, false),
+                    0, builder.length(), Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+            return builder;
+        }
+    }
+
+    private static ChipSpan constructChipSpan(
+            TextView view, CharSequence text, boolean pressed) {
+        final Layout layout = view.getLayout();
+        final Resources res = view.getContext().getResources();
+        final TextPaint paint = view.getPaint();
+
+        int line = layout.getLineForOffset(0);
+        int lineTop = layout.getLineTop(line);
+        int lineBottom = layout.getLineBottom(line);
+        int lineBaseline = layout.getLineBaseline(line);
+        int ascent = layout.getLineAscent(line);
+        int descent = layout.getLineDescent(line);
+        int width = (int) Math.floor(paint.measureText(text, 0, text.length()));
+        int height = lineBottom - lineTop;
+
+        Bitmap tmpBitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+        Canvas canvas = new Canvas(tmpBitmap);
+        if (pressed) {
+            canvas.drawColor(0xFFDDFFFF);
+        } else {
+            canvas.drawColor(0xFF00FFFF);
+        }
+
+        canvas.drawText(text, 0, text.length(), 0, Math.abs(ascent), paint);
+
+        Drawable result = new BitmapDrawable(res, tmpBitmap);
+        result.setBounds(0, 0, width, height);
+        return new ChipSpan(result, text);
+    }
+
+    private static class ChipSpan extends ImageSpan {
+        private final CharSequence mText;
+
+        public ChipSpan(Drawable drawable, CharSequence text) {
+            super(drawable);
+            mText = text;
+        }
+
+        public CharSequence getText() {
+            return mText;
+        }
+    }
+}
\ No newline at end of file
diff --git a/java/com/android/ex/chips/RecipientListEntry.java b/java/com/android/ex/chips/RecipientListEntry.java
new file mode 100644
index 0000000..6e69649
--- /dev/null
+++ b/java/com/android/ex/chips/RecipientListEntry.java
@@ -0,0 +1,99 @@
+/*
+ * Copyright (C) 2011 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package com.android.ex.chips;
+
+/**
+ * Represents one entry inside recipient auto-complete list.
+ */
+public class RecipientListEntry {
+    /** Separator entry dividing two persons or groups. */
+    public static final RecipientListEntry SEP_NORMAL = new RecipientListEntry();
+    /** Separator entry dividing two entries inside a person or a group. */
+    public static final RecipientListEntry SEP_WITHIN_GROUP = new RecipientListEntry();
+
+    /**
+     * True when this entry is the first entry in a group, which should have a photo and display
+     * name, while the second or later entries won't.
+     */
+    private boolean mIsFirstLevel;
+    private final String mDisplayName;
+    /** Destination for this contact entry. Would be an email address or a phone number. */
+    private final String mDestination;
+    private final int mContactId;
+    private final boolean mIsDivider;
+    private byte[] mPhotoBytes;
+
+    private RecipientListEntry() {
+        mDisplayName = null;
+        mDestination = null;
+        mContactId = -1;
+        mPhotoBytes = null;
+        mIsDivider = true;
+    }
+
+    private RecipientListEntry(String displayName, String destination, int contactId) {
+        mIsFirstLevel = false;
+        mDisplayName = displayName;
+        mDestination = destination;
+        mContactId = contactId;
+        mIsDivider = false;
+    }
+
+    private RecipientListEntry(
+            String displayName, String destination, int contactId, byte[] photoBytes) {
+        mIsFirstLevel = true;
+        mDisplayName = displayName;
+        mDestination = destination;
+        mContactId = contactId;
+        mIsDivider = false;
+        mPhotoBytes = photoBytes;
+    }
+
+    public static RecipientListEntry constructTopLevelEntry(
+            String displayName, String destination, int contactId, byte[] photoBytes) {
+        return new RecipientListEntry(displayName, destination, contactId, photoBytes);
+    }
+
+    public static RecipientListEntry constructSecondLevelEntry(
+            String displayName, String destination, int contactId) {
+        return new RecipientListEntry(displayName, destination, contactId);
+    }
+
+    public String getDisplayName() {
+        return mDisplayName;
+    }
+
+    public String getDestination() {
+        return mDestination;
+    }
+
+    public int getContactId() {
+        return mContactId;
+    }
+
+    public boolean isFirstLevel() {
+        return mIsFirstLevel;
+    }
+
+    public byte[] getPhotoBytes() {
+        return mPhotoBytes;
+    }
+
+    public boolean isSeparator() {
+        return mIsDivider;
+    }
+}
\ No newline at end of file