New display options activity

New activity and a new hacky way to invoke it from
the filter spinner.
Also a custom item view for the spinner.

Change-Id: I00e3854023d8602ea94dc7d11f2961b7efeaca38
diff --git a/AndroidManifest.xml b/AndroidManifest.xml
index b67b010..d6b6bf2 100644
--- a/AndroidManifest.xml
+++ b/AndroidManifest.xml
@@ -307,6 +307,12 @@
             android:label="@string/displayGroups"
             android:theme="@style/ContactsPreferencesTheme" />
 
+        <!-- Used to select display and sync groups -->
+        <activity
+            android:name=".list.CustomContactListFilterActivity"
+            android:label="@string/custom_list_filter"
+            android:theme="@style/ContactsPreferencesTheme" />
+
         <activity
             android:name=".ui.ShowOrCreateActivity"
             android:theme="@style/FullyTranslucent">
diff --git a/res/layout-xlarge/contacts_list_content.xml b/res/layout-xlarge/contacts_list_content.xml
index 0ef0544..e5af112 100644
--- a/res/layout-xlarge/contacts_list_content.xml
+++ b/res/layout-xlarge/contacts_list_content.xml
@@ -22,7 +22,8 @@
         android:orientation="horizontal"
         >
 
-    <com.android.contacts.list.ContactListAizyView
+    <view
+        class="com.android.contacts.list.ContactListAizyView"
         android:id="@+id/contacts_list_aizy"
         android:layout_width="40dip"
         android:layout_height="match_parent"
@@ -35,7 +36,8 @@
             android:layout_weight="1"
             >
 
-        <Spinner
+        <view
+            class="com.android.contacts.widget.NotifyingSpinner"
             android:id="@+id/filter_spinner"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
diff --git a/res/layout/contacts_list_content.xml b/res/layout/contacts_list_content.xml
index 0737bbc..34d629f 100644
--- a/res/layout/contacts_list_content.xml
+++ b/res/layout/contacts_list_content.xml
@@ -29,7 +29,8 @@
             android:layout_weight="1"
             >
 
-        <Spinner
+        <view
+            class="com.android.contacts.widget.NotifyingSpinner"
             android:id="@+id/filter_spinner"
             android:layout_width="match_parent"
             android:layout_height="wrap_content"
@@ -54,7 +55,8 @@
             android:layout_height="wrap_content"
         />
     </LinearLayout>
-    <com.android.contacts.list.ContactListAizyView
+    <view
+        class="com.android.contacts.list.ContactListAizyView"
         android:id="@+id/contacts_list_aizy"
         android:layout_width="30dip"
         android:layout_height="match_parent"
diff --git a/res/layout/custom_list_filter.xml b/res/layout/custom_list_filter.xml
new file mode 100644
index 0000000..5176ed2
--- /dev/null
+++ b/res/layout/custom_list_filter.xml
@@ -0,0 +1,62 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2010 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.
+-->
+
+<LinearLayout
+        xmlns:android="http://schemas.android.com/apk/res/android"
+        android:id="@+id/pinned_header_list_layout"
+        android:layout_width="match_parent"
+        android:layout_height="match_parent"
+        android:orientation="horizontal"
+        >
+
+    <LinearLayout
+            android:layout_width="0px"
+            android:layout_height="match_parent"
+            android:orientation="vertical"
+            android:layout_weight="1"
+            >
+
+        <Spinner
+            android:id="@+id/filter_spinner"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:prompt="@string/list_filter_prompt"
+            android:visibility="gone"
+        />
+
+        <view
+            class="com.android.contacts.ContactEntryListView"
+            android:id="@android:id/list"
+            android:layout_width="match_parent"
+            android:layout_height="0dip"
+            android:fastScrollEnabled="true"
+            android:layout_weight="1"
+        />
+
+        <include layout="@layout/contacts_list_empty"/>
+
+        <ViewStub android:id="@+id/footer_stub"
+            android:layout="@layout/footer_panel"
+            android:layout_width="fill_parent"
+            android:layout_height="wrap_content"
+        />
+    </LinearLayout>
+    <com.android.contacts.list.ContactListAizyView
+        android:id="@+id/contacts_list_aizy"
+        android:layout_width="30dip"
+        android:layout_height="match_parent"
+    />
+</LinearLayout>
diff --git a/res/layout/filter_spinner_item.xml b/res/layout/filter_spinner_item.xml
index 79c871c..b05a019 100644
--- a/res/layout/filter_spinner_item.xml
+++ b/res/layout/filter_spinner_item.xml
@@ -14,8 +14,9 @@
      limitations under the License.
 -->
 
-<LinearLayout
+<view
     xmlns:android="http://schemas.android.com/apk/res/android"
+    class="com.android.contacts.list.FilterSpinnerItemView"
     android:layout_height="52dip"
     android:layout_width="fill_parent"
     android:paddingLeft="7dip"
@@ -47,5 +48,5 @@
         android:gravity="center_vertical"
         android:ellipsize="end"
         android:paddingLeft="80dip" />
-</LinearLayout>
+</view>
 
diff --git a/res/values/strings.xml b/res/values/strings.xml
index 5478665..30134a9 100644
--- a/res/values/strings.xml
+++ b/res/values/strings.xml
@@ -1324,4 +1324,7 @@
 
     <!-- Contact list filter selection indicating that the list shows groups chosen by the user [CHAR LIMIT=64] -->
     <string name="list_filter_customize">Customize...</string>
+
+    <!-- Title of the activity that allows the user to customize filtering of contact list [CHAR LIMIT=128] -->
+    <string name="custom_list_filter">Custom contact list</string>
 </resources>
diff --git a/src/com/android/contacts/list/CustomContactListFilterActivity.java b/src/com/android/contacts/list/CustomContactListFilterActivity.java
new file mode 100644
index 0000000..b6c6f4c
--- /dev/null
+++ b/src/com/android/contacts/list/CustomContactListFilterActivity.java
@@ -0,0 +1,919 @@
+/*
+ * Copyright (C) 2009 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.contacts.list;
+
+import com.android.contacts.ContactsSearchManager;
+import com.android.contacts.R;
+import com.android.contacts.model.ContactsSource;
+import com.android.contacts.model.EntityDelta.ValuesDelta;
+import com.android.contacts.model.GoogleSource;
+import com.android.contacts.model.Sources;
+import com.android.contacts.util.EmptyService;
+import com.android.contacts.util.LocalizedNameResolver;
+import com.android.contacts.util.WeakAsyncTask;
+import com.google.android.collect.Lists;
+
+import android.accounts.Account;
+import android.app.Activity;
+import android.app.AlertDialog;
+import android.app.ExpandableListActivity;
+import android.app.ProgressDialog;
+import android.content.ContentProviderOperation;
+import android.content.ContentProviderOperation.Builder;
+import android.content.ContentResolver;
+import android.content.ContentValues;
+import android.content.Context;
+import android.content.DialogInterface;
+import android.content.EntityIterator;
+import android.content.Intent;
+import android.content.OperationApplicationException;
+import android.content.SharedPreferences;
+import android.content.SharedPreferences.Editor;
+import android.database.Cursor;
+import android.net.Uri;
+import android.os.Bundle;
+import android.os.RemoteException;
+import android.preference.PreferenceManager;
+import android.provider.ContactsContract;
+import android.provider.ContactsContract.Groups;
+import android.provider.ContactsContract.Settings;
+import android.util.Log;
+import android.view.ContextMenu;
+import android.view.LayoutInflater;
+import android.view.MenuItem;
+import android.view.MenuItem.OnMenuItemClickListener;
+import android.view.View;
+import android.view.ViewGroup;
+import android.widget.AdapterView;
+import android.widget.BaseExpandableListAdapter;
+import android.widget.CheckBox;
+import android.widget.ExpandableListAdapter;
+import android.widget.ExpandableListView;
+import android.widget.ExpandableListView.ExpandableListContextMenuInfo;
+import android.widget.TextView;
+
+import java.lang.ref.WeakReference;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.Comparator;
+import java.util.Iterator;
+
+/**
+ * Shows a list of all available {@link Groups} available, letting the user
+ * select which ones they want to be visible.
+ */
+public final class CustomContactListFilterActivity extends ExpandableListActivity implements
+        AdapterView.OnItemClickListener, View.OnClickListener {
+
+    private static final String TAG = "CustomContactListFilterActivity";
+
+    public static final boolean PREF_DISPLAY_ONLY_PHONES_DEFAULT = false;
+    public static final String PREF_DISPLAY_ONLY_PHONES = "only_phones";
+
+    private ExpandableListView mList;
+    private DisplayAdapter mAdapter;
+
+    private SharedPreferences mPrefs;
+
+    private CheckBox mDisplayPhones;
+
+    private View mHeaderPhones;
+    private View mHeaderSeparator;
+
+    @Override
+    protected void onCreate(Bundle icicle) {
+        super.onCreate(icicle);
+        setContentView(R.layout.contacts_preferences);
+
+        mList = getExpandableListView();
+        mList.setHeaderDividersEnabled(true);
+        mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
+        mAdapter = new DisplayAdapter(this);
+
+        final LayoutInflater inflater = getLayoutInflater();
+
+        createWithPhonesOnlyPreferenceView(inflater);
+        createDisplayGroupHeader(inflater);
+
+        findViewById(R.id.btn_done).setOnClickListener(this);
+        findViewById(R.id.btn_discard).setOnClickListener(this);
+
+        // Catch clicks on the header views
+        mList.setOnItemClickListener(this);
+        mList.setOnCreateContextMenuListener(this);
+    }
+
+    private void createWithPhonesOnlyPreferenceView(LayoutInflater inflater) {
+        // Add the "Only contacts with phones" header modifier.
+        mHeaderPhones = inflater.inflate(R.layout.display_options_phones_only, mList, false);
+        mHeaderPhones.setId(R.id.header_phones);
+        mDisplayPhones = (CheckBox) mHeaderPhones.findViewById(android.R.id.checkbox);
+        mDisplayPhones.setChecked(
+                mPrefs.getBoolean(PREF_DISPLAY_ONLY_PHONES, PREF_DISPLAY_ONLY_PHONES_DEFAULT));
+        {
+            final TextView text1 = (TextView)mHeaderPhones.findViewById(android.R.id.text1);
+            final TextView text2 = (TextView)mHeaderPhones.findViewById(android.R.id.text2);
+            text1.setText(R.string.showFilterPhones);
+            text2.setText(R.string.showFilterPhonesDescrip);
+        }
+    }
+
+    private void createDisplayGroupHeader(LayoutInflater inflater) {
+        // Add the separator before showing the detailed group list.
+        mHeaderSeparator = inflater.inflate(R.layout.list_separator, mList, false);
+        {
+            final TextView text1 = (TextView)mHeaderSeparator;
+            text1.setText(R.string.headerContactGroups);
+        }
+    }
+
+    @Override
+    protected void onResume() {
+        super.onResume();
+        mList.removeHeaderView(mHeaderPhones);
+        mList.removeHeaderView(mHeaderSeparator);
+
+        // List adapter needs to be reset, because header views cannot be added
+        // to a list with an existing adapter.
+        setListAdapter(null);
+
+        mList.addHeaderView(mHeaderPhones, null, true);
+        mList.addHeaderView(mHeaderSeparator, null, false);
+
+        setListAdapter(mAdapter);
+
+        // Start background query to find account details
+        new QueryGroupsTask(this).execute();
+    }
+
+    /**
+     * Background operation to build set of {@link AccountDisplay} for each
+     * {@link Sources#getAccounts(boolean)} that provides groups.
+     */
+    private static class QueryGroupsTask extends
+            WeakAsyncTask<Void, Void, AccountSet, CustomContactListFilterActivity> {
+        public QueryGroupsTask(CustomContactListFilterActivity target) {
+            super(target);
+        }
+
+        @Override
+        protected AccountSet doInBackground(CustomContactListFilterActivity target,
+                Void... params) {
+            final Context context = target;
+            final Sources sources = Sources.getInstance(context);
+            final ContentResolver resolver = context.getContentResolver();
+
+            // Inflate groups entry for each account
+            final AccountSet accounts = new AccountSet();
+            for (Account account : sources.getAccounts(false)) {
+                accounts.add(new AccountDisplay(resolver, account.name, account.type));
+            }
+
+            return accounts;
+        }
+
+        @Override
+        protected void onPostExecute(CustomContactListFilterActivity target, AccountSet result) {
+            target.mAdapter.setAccounts(result);
+        }
+    }
+
+    private static final int DEFAULT_SHOULD_SYNC = 1;
+    private static final int DEFAULT_VISIBLE = 0;
+
+    /**
+     * Entry holding any changes to {@link Groups} or {@link Settings} rows,
+     * such as {@link Groups#SHOULD_SYNC} or {@link Groups#GROUP_VISIBLE}.
+     */
+    protected static class GroupDelta extends ValuesDelta {
+        private boolean mUngrouped = false;
+        private boolean mAccountHasGroups;
+
+        private GroupDelta() {
+            super();
+        }
+
+        /**
+         * Build {@link GroupDelta} from the {@link Settings} row for the given
+         * {@link Settings#ACCOUNT_NAME} and {@link Settings#ACCOUNT_TYPE}.
+         */
+        public static GroupDelta fromSettings(ContentResolver resolver, String accountName,
+                String accountType, boolean accountHasGroups) {
+            final Uri settingsUri = Settings.CONTENT_URI.buildUpon()
+                    .appendQueryParameter(Settings.ACCOUNT_NAME, accountName)
+                    .appendQueryParameter(Settings.ACCOUNT_TYPE, accountType).build();
+            final Cursor cursor = resolver.query(settingsUri, new String[] {
+                    Settings.SHOULD_SYNC, Settings.UNGROUPED_VISIBLE
+            }, null, null, null);
+
+            try {
+                final ContentValues values = new ContentValues();
+                values.put(Settings.ACCOUNT_NAME, accountName);
+                values.put(Settings.ACCOUNT_TYPE, accountType);
+
+                if (cursor != null && cursor.moveToFirst()) {
+                    // Read existing values when present
+                    values.put(Settings.SHOULD_SYNC, cursor.getInt(0));
+                    values.put(Settings.UNGROUPED_VISIBLE, cursor.getInt(1));
+                    return fromBefore(values).setUngrouped(accountHasGroups);
+                } else {
+                    // Nothing found, so treat as create
+                    values.put(Settings.SHOULD_SYNC, DEFAULT_SHOULD_SYNC);
+                    values.put(Settings.UNGROUPED_VISIBLE, DEFAULT_VISIBLE);
+                    return fromAfter(values).setUngrouped(accountHasGroups);
+                }
+            } finally {
+                if (cursor != null) cursor.close();
+            }
+        }
+
+        public static GroupDelta fromBefore(ContentValues before) {
+            final GroupDelta entry = new GroupDelta();
+            entry.mBefore = before;
+            entry.mAfter = new ContentValues();
+            return entry;
+        }
+
+        public static GroupDelta fromAfter(ContentValues after) {
+            final GroupDelta entry = new GroupDelta();
+            entry.mBefore = null;
+            entry.mAfter = after;
+            return entry;
+        }
+
+        protected GroupDelta setUngrouped(boolean accountHasGroups) {
+            mUngrouped = true;
+            mAccountHasGroups = accountHasGroups;
+            return this;
+        }
+
+        @Override
+        public boolean beforeExists() {
+            return mBefore != null;
+        }
+
+        public boolean getShouldSync() {
+            return getAsInteger(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC,
+                    DEFAULT_SHOULD_SYNC) != 0;
+        }
+
+        public boolean getVisible() {
+            return getAsInteger(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE,
+                    DEFAULT_VISIBLE) != 0;
+        }
+
+        public void putShouldSync(boolean shouldSync) {
+            put(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC, shouldSync ? 1 : 0);
+        }
+
+        public void putVisible(boolean visible) {
+            put(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE, visible ? 1 : 0);
+        }
+
+        private String getAccountType() {
+            return (mBefore == null ? mAfter : mBefore).getAsString(Settings.ACCOUNT_TYPE);
+        }
+
+        public CharSequence getTitle(Context context) {
+            if (mUngrouped) {
+                final String customAllContactsName =
+                        LocalizedNameResolver.getAllContactsName(context, getAccountType());
+                if (customAllContactsName != null) {
+                    return customAllContactsName;
+                }
+                if (mAccountHasGroups) {
+                    return context.getText(R.string.display_ungrouped);
+                } else {
+                    return context.getText(R.string.display_all_contacts);
+                }
+            } else {
+                final Integer titleRes = getAsInteger(Groups.TITLE_RES);
+                if (titleRes != null) {
+                    final String packageName = getAsString(Groups.RES_PACKAGE);
+                    return context.getPackageManager().getText(packageName, titleRes, null);
+                } else {
+                    return getAsString(Groups.TITLE);
+                }
+            }
+        }
+
+        /**
+         * Build a possible {@link ContentProviderOperation} to persist any
+         * changes to the {@link Groups} or {@link Settings} row described by
+         * this {@link GroupDelta}.
+         */
+        public ContentProviderOperation buildDiff() {
+            if (isNoop()) {
+                return null;
+            } else if (isUpdate()) {
+                // When has changes and "before" exists, then "update"
+                final Builder builder = ContentProviderOperation.newUpdate(
+                        mUngrouped
+                                ? Settings.CONTENT_URI
+                                : addCallerIsSyncAdapterParameter(Groups.CONTENT_URI));
+                if (mUngrouped) {
+                    builder.withSelection(Settings.ACCOUNT_NAME + "=? AND " + Settings.ACCOUNT_TYPE
+                            + "=?", new String[] {
+                            this.getAsString(Settings.ACCOUNT_NAME),
+                            this.getAsString(Settings.ACCOUNT_TYPE)
+                    });
+                } else {
+                    builder.withSelection(Groups._ID + "=" + this.getId(), null);
+                }
+                builder.withValues(mAfter);
+                return builder.build();
+            } else if (isInsert() && mUngrouped) {
+                // Only allow inserts for Settings
+                mAfter.remove(mIdColumn);
+                final Builder builder = ContentProviderOperation.newInsert(Settings.CONTENT_URI);
+                builder.withValues(mAfter);
+                return builder.build();
+            } else {
+                throw new IllegalStateException("Unexpected delete or insert");
+            }
+        }
+    }
+
+    private static Uri addCallerIsSyncAdapterParameter(Uri uri) {
+        return uri.buildUpon()
+            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
+            .build();
+    }
+
+    /**
+     * {@link Comparator} to sort by {@link Groups#_ID}.
+     */
+    private static Comparator<GroupDelta> sIdComparator = new Comparator<GroupDelta>() {
+        public int compare(GroupDelta object1, GroupDelta object2) {
+            final Long id1 = object1.getId();
+            final Long id2 = object2.getId();
+            if (id1 == null && id2 == null) {
+                return 0;
+            } else if (id1 == null) {
+                return -1;
+            } else if (id2 == null) {
+                return 1;
+            } else if (id1 < id2) {
+                return -1;
+            } else if (id1 > id2) {
+                return 1;
+            } else {
+                return 0;
+            }
+        }
+    };
+
+    /**
+     * Set of all {@link AccountDisplay} entries, one for each source.
+     */
+    protected static class AccountSet extends ArrayList<AccountDisplay> {
+        public ArrayList<ContentProviderOperation> buildDiff() {
+            final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
+            for (AccountDisplay account : this) {
+                account.buildDiff(diff);
+            }
+            return diff;
+        }
+    }
+
+    /**
+     * {@link GroupDelta} details for a single {@link Account}, usually shown as
+     * children under a single expandable group.
+     */
+    protected static class AccountDisplay {
+        public String mName;
+        public String mType;
+
+        public GroupDelta mUngrouped;
+        public ArrayList<GroupDelta> mSyncedGroups = Lists.newArrayList();
+        public ArrayList<GroupDelta> mUnsyncedGroups = Lists.newArrayList();
+
+        /**
+         * Build an {@link AccountDisplay} covering all {@link Groups} under the
+         * given {@link Account}.
+         */
+        public AccountDisplay(ContentResolver resolver, String accountName, String accountType) {
+            mName = accountName;
+            mType = accountType;
+
+            final Uri groupsUri = Groups.CONTENT_URI.buildUpon()
+                    .appendQueryParameter(Groups.ACCOUNT_NAME, accountName)
+                    .appendQueryParameter(Groups.ACCOUNT_TYPE, accountType).build();
+            EntityIterator iterator = ContactsContract.Groups.newEntityIterator(resolver.query(
+                    groupsUri, null, null, null, null));
+            try {
+                boolean hasGroups = false;
+
+                // Create entries for each known group
+                while (iterator.hasNext()) {
+                    final ContentValues values = iterator.next().getEntityValues();
+                    final GroupDelta group = GroupDelta.fromBefore(values);
+                    addGroup(group);
+                    hasGroups = true;
+                }
+                // Create single entry handling ungrouped status
+                mUngrouped = GroupDelta.fromSettings(resolver, accountName, accountType, hasGroups);
+                addGroup(mUngrouped);
+            } finally {
+                iterator.close();
+            }
+        }
+
+        /**
+         * Add the given {@link GroupDelta} internally, filing based on its
+         * {@link GroupDelta#getShouldSync()} status.
+         */
+        private void addGroup(GroupDelta group) {
+            if (group.getShouldSync()) {
+                mSyncedGroups.add(group);
+            } else {
+                mUnsyncedGroups.add(group);
+            }
+        }
+
+        /**
+         * Set the {@link GroupDelta#putShouldSync(boolean)} value for all
+         * children {@link GroupDelta} rows.
+         */
+        public void setShouldSync(boolean shouldSync) {
+            final Iterator<GroupDelta> oppositeChildren = shouldSync ?
+                    mUnsyncedGroups.iterator() : mSyncedGroups.iterator();
+            while (oppositeChildren.hasNext()) {
+                final GroupDelta child = oppositeChildren.next();
+                setShouldSync(child, shouldSync, false);
+                oppositeChildren.remove();
+            }
+        }
+
+        public void setShouldSync(GroupDelta child, boolean shouldSync) {
+            setShouldSync(child, shouldSync, true);
+        }
+
+        /**
+         * Set {@link GroupDelta#putShouldSync(boolean)}, and file internally
+         * based on updated state.
+         */
+        public void setShouldSync(GroupDelta child, boolean shouldSync, boolean attemptRemove) {
+            child.putShouldSync(shouldSync);
+            if (shouldSync) {
+                if (attemptRemove) {
+                    mUnsyncedGroups.remove(child);
+                }
+                mSyncedGroups.add(child);
+                Collections.sort(mSyncedGroups, sIdComparator);
+            } else {
+                if (attemptRemove) {
+                    mSyncedGroups.remove(child);
+                }
+                mUnsyncedGroups.add(child);
+            }
+        }
+
+        /**
+         * Build set of {@link ContentProviderOperation} to persist any user
+         * changes to {@link GroupDelta} rows under this {@link Account}.
+         */
+        public void buildDiff(ArrayList<ContentProviderOperation> diff) {
+            for (GroupDelta group : mSyncedGroups) {
+                final ContentProviderOperation oper = group.buildDiff();
+                if (oper != null) diff.add(oper);
+            }
+            for (GroupDelta group : mUnsyncedGroups) {
+                final ContentProviderOperation oper = group.buildDiff();
+                if (oper != null) diff.add(oper);
+            }
+        }
+    }
+
+    /**
+     * {@link ExpandableListAdapter} that shows {@link GroupDelta} settings,
+     * grouped by {@link Account} source. Shows footer row when any groups are
+     * unsynced, as determined through {@link AccountDisplay#mUnsyncedGroups}.
+     */
+    protected static class DisplayAdapter extends BaseExpandableListAdapter {
+        private Context mContext;
+        private LayoutInflater mInflater;
+        private Sources mSources;
+        private AccountSet mAccounts;
+
+        private boolean mChildWithPhones = false;
+
+        public DisplayAdapter(Context context) {
+            mContext = context;
+            mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            mSources = Sources.getInstance(context);
+        }
+
+        public void setAccounts(AccountSet accounts) {
+            mAccounts = accounts;
+            notifyDataSetChanged();
+        }
+
+        /**
+         * In group descriptions, show the number of contacts with phone
+         * numbers, in addition to the total contacts.
+         */
+        public void setChildDescripWithPhones(boolean withPhones) {
+            mChildWithPhones = withPhones;
+        }
+
+        /** {@inheritDoc} */
+        public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
+                View convertView, ViewGroup parent) {
+            if (convertView == null) {
+                convertView = mInflater.inflate(R.layout.display_child, parent, false);
+            }
+
+            final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
+            final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
+            final CheckBox checkbox = (CheckBox)convertView.findViewById(android.R.id.checkbox);
+
+            final AccountDisplay account = mAccounts.get(groupPosition);
+            final GroupDelta child = (GroupDelta)this.getChild(groupPosition, childPosition);
+            if (child != null) {
+                // Handle normal group, with title and checkbox
+                final boolean groupVisible = child.getVisible();
+                checkbox.setVisibility(View.VISIBLE);
+                checkbox.setChecked(groupVisible);
+
+                final CharSequence groupTitle = child.getTitle(mContext);
+                text1.setText(groupTitle);
+
+//              final int count = cursor.getInt(GroupsQuery.SUMMARY_COUNT);
+//              final int withPhones = cursor.getInt(GroupsQuery.SUMMARY_WITH_PHONES);
+
+//              final CharSequence descrip = mContext.getResources().getQuantityString(
+//                      mChildWithPhones ? R.plurals.groupDescripPhones : R.plurals.groupDescrip,
+//                      count, count, withPhones);
+
+//              text2.setText(descrip);
+                text2.setVisibility(View.GONE);
+            } else {
+                // When unknown child, this is "more" footer view
+                checkbox.setVisibility(View.GONE);
+                text1.setText(R.string.display_more_groups);
+                text2.setVisibility(View.GONE);
+            }
+
+            return convertView;
+        }
+
+        /** {@inheritDoc} */
+        public View getGroupView(int groupPosition, boolean isExpanded, View convertView,
+                ViewGroup parent) {
+            if (convertView == null) {
+                convertView = mInflater.inflate(R.layout.display_group, parent, false);
+            }
+
+            final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
+            final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
+
+            final AccountDisplay account = (AccountDisplay)this.getGroup(groupPosition);
+
+            final ContactsSource source = mSources.getInflatedSource(account.mType,
+                    ContactsSource.LEVEL_SUMMARY);
+
+            text1.setText(account.mName);
+            text2.setText(source.getDisplayLabel(mContext));
+            text2.setVisibility(account.mName == null ? View.GONE : View.VISIBLE);
+
+            return convertView;
+        }
+
+        /** {@inheritDoc} */
+        public Object getChild(int groupPosition, int childPosition) {
+            final AccountDisplay account = mAccounts.get(groupPosition);
+            final boolean validChild = childPosition >= 0
+                    && childPosition < account.mSyncedGroups.size();
+            if (validChild) {
+                return account.mSyncedGroups.get(childPosition);
+            } else {
+                return null;
+            }
+        }
+
+        /** {@inheritDoc} */
+        public long getChildId(int groupPosition, int childPosition) {
+            final GroupDelta child = (GroupDelta)getChild(groupPosition, childPosition);
+            if (child != null) {
+                final Long childId = child.getId();
+                return childId != null ? childId : Long.MIN_VALUE;
+            } else {
+                return Long.MIN_VALUE;
+            }
+        }
+
+        /** {@inheritDoc} */
+        public int getChildrenCount(int groupPosition) {
+            // Count is any synced groups, plus possible footer
+            final AccountDisplay account = mAccounts.get(groupPosition);
+            final boolean anyHidden = account.mUnsyncedGroups.size() > 0;
+            return account.mSyncedGroups.size() + (anyHidden ? 1 : 0);
+        }
+
+        /** {@inheritDoc} */
+        public Object getGroup(int groupPosition) {
+            return mAccounts.get(groupPosition);
+        }
+
+        /** {@inheritDoc} */
+        public int getGroupCount() {
+            if (mAccounts == null) {
+                return 0;
+            }
+            return mAccounts.size();
+        }
+
+        /** {@inheritDoc} */
+        public long getGroupId(int groupPosition) {
+            return groupPosition;
+        }
+
+        /** {@inheritDoc} */
+        public boolean hasStableIds() {
+            return true;
+        }
+
+        /** {@inheritDoc} */
+        public boolean isChildSelectable(int groupPosition, int childPosition) {
+            return true;
+        }
+    }
+
+    /**
+     * Handle any clicks on header views added to our {@link #mAdapter}, which
+     * are usually the global modifier checkboxes.
+     */
+    public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
+        Log.d(TAG, "OnItemClick, position=" + position + ", id=" + id);
+        if (view == mHeaderPhones) {
+            mDisplayPhones.toggle();
+            return;
+        }
+    }
+
+    /** {@inheritDoc} */
+    public void onClick(View view) {
+        switch (view.getId()) {
+            case R.id.btn_done: {
+                this.doSaveAction();
+                break;
+            }
+            case R.id.btn_discard: {
+                this.finish();
+                break;
+            }
+        }
+    }
+
+    /**
+     * Assign a specific value to {@link #PREF_DISPLAY_ONLY_PHONES}, refreshing
+     * the visible list as needed.
+     */
+    protected void setDisplayOnlyPhones(boolean displayOnlyPhones) {
+        mDisplayPhones.setChecked(displayOnlyPhones);
+
+        Editor editor = mPrefs.edit();
+        editor.putBoolean(PREF_DISPLAY_ONLY_PHONES, displayOnlyPhones);
+        editor.apply();
+
+        mAdapter.setChildDescripWithPhones(displayOnlyPhones);
+        mAdapter.notifyDataSetChanged();
+    }
+
+    /**
+     * Handle any clicks on {@link ExpandableListAdapter} children, which
+     * usually mean toggling its visible state.
+     */
+    @Override
+    public boolean onChildClick(ExpandableListView parent, View view, int groupPosition,
+            int childPosition, long id) {
+        final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox);
+
+        final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
+        final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
+        if (child != null) {
+            checkbox.toggle();
+            child.putVisible(checkbox.isChecked());
+        } else {
+            // Open context menu for bringing back unsynced
+            this.openContextMenu(view);
+        }
+        return true;
+    }
+
+    // TODO: move these definitions to framework constants when we begin
+    // defining this mode through <sync-adapter> tags
+    private static final int SYNC_MODE_UNSUPPORTED = 0;
+    private static final int SYNC_MODE_UNGROUPED = 1;
+    private static final int SYNC_MODE_EVERYTHING = 2;
+
+    protected int getSyncMode(AccountDisplay account) {
+        // TODO: read sync mode through <sync-adapter> definition
+        if (GoogleSource.ACCOUNT_TYPE.equals(account.mType)) {
+            return SYNC_MODE_EVERYTHING;
+        } else {
+            return SYNC_MODE_UNSUPPORTED;
+        }
+    }
+
+    @Override
+    public void onCreateContextMenu(ContextMenu menu, View view,
+            ContextMenu.ContextMenuInfo menuInfo) {
+        super.onCreateContextMenu(menu, view, menuInfo);
+
+        // Bail if not working with expandable long-press, or if not child
+        if (!(menuInfo instanceof ExpandableListContextMenuInfo)) return;
+
+        final ExpandableListContextMenuInfo info = (ExpandableListContextMenuInfo) menuInfo;
+        final int groupPosition = ExpandableListView.getPackedPositionGroup(info.packedPosition);
+        final int childPosition = ExpandableListView.getPackedPositionChild(info.packedPosition);
+
+        // Skip long-press on expandable parents
+        if (childPosition == -1) return;
+
+        final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
+        final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
+
+        // Ignore when selective syncing unsupported
+        final int syncMode = getSyncMode(account);
+        if (syncMode == SYNC_MODE_UNSUPPORTED) return;
+
+        if (child != null) {
+            showRemoveSync(menu, account, child, syncMode);
+        } else {
+            showAddSync(menu, account, syncMode);
+        }
+    }
+
+    protected void showRemoveSync(ContextMenu menu, final AccountDisplay account,
+            final GroupDelta child, final int syncMode) {
+        final CharSequence title = child.getTitle(this);
+
+        menu.setHeaderTitle(title);
+        menu.add(R.string.menu_sync_remove).setOnMenuItemClickListener(
+                new OnMenuItemClickListener() {
+                    public boolean onMenuItemClick(MenuItem item) {
+                        handleRemoveSync(account, child, syncMode, title);
+                        return true;
+                    }
+                });
+    }
+
+    protected void handleRemoveSync(final AccountDisplay account, final GroupDelta child,
+            final int syncMode, CharSequence title) {
+        final boolean shouldSyncUngrouped = account.mUngrouped.getShouldSync();
+        if (syncMode == SYNC_MODE_EVERYTHING && shouldSyncUngrouped
+                && !child.equals(account.mUngrouped)) {
+            // Warn before removing this group when it would cause ungrouped to stop syncing
+            final AlertDialog.Builder builder = new AlertDialog.Builder(this);
+            final CharSequence removeMessage = this.getString(
+                    R.string.display_warn_remove_ungrouped, title);
+            builder.setTitle(R.string.menu_sync_remove);
+            builder.setMessage(removeMessage);
+            builder.setNegativeButton(android.R.string.cancel, null);
+            builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
+                public void onClick(DialogInterface dialog, int which) {
+                    // Mark both this group and ungrouped to stop syncing
+                    account.setShouldSync(account.mUngrouped, false);
+                    account.setShouldSync(child, false);
+                    mAdapter.notifyDataSetChanged();
+                }
+            });
+            builder.show();
+        } else {
+            // Mark this group to not sync
+            account.setShouldSync(child, false);
+            mAdapter.notifyDataSetChanged();
+        }
+    }
+
+    protected void showAddSync(ContextMenu menu, final AccountDisplay account, final int syncMode) {
+        menu.setHeaderTitle(R.string.dialog_sync_add);
+
+        // Create item for each available, unsynced group
+        for (final GroupDelta child : account.mUnsyncedGroups) {
+            if (!child.getShouldSync()) {
+                final CharSequence title = child.getTitle(this);
+                menu.add(title).setOnMenuItemClickListener(new OnMenuItemClickListener() {
+                    public boolean onMenuItemClick(MenuItem item) {
+                        // Adding specific group for syncing
+                        if (child.mUngrouped && syncMode == SYNC_MODE_EVERYTHING) {
+                            account.setShouldSync(true);
+                        } else {
+                            account.setShouldSync(child, true);
+                        }
+                        mAdapter.notifyDataSetChanged();
+                        return true;
+                    }
+                });
+            }
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void onBackPressed() {
+        doSaveAction();
+    }
+
+    private void doSaveAction() {
+        if (mAdapter == null || mAdapter.mAccounts == null) {
+            return;
+        }
+        setDisplayOnlyPhones(mDisplayPhones.isChecked());
+        setResult(RESULT_OK);
+        new UpdateTask(this).execute(mAdapter.mAccounts);
+    }
+
+    /**
+     * Background task that persists changes to {@link Groups#GROUP_VISIBLE},
+     * showing spinner dialog to user while updating.
+     */
+    public static class UpdateTask extends
+            WeakAsyncTask<AccountSet, Void, Void, Activity> {
+        private WeakReference<ProgressDialog> mProgress;
+
+        public UpdateTask(Activity target) {
+            super(target);
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void onPreExecute(Activity target) {
+            final Context context = target;
+
+            mProgress = new WeakReference<ProgressDialog>(ProgressDialog.show(context, null,
+                    context.getText(R.string.savingDisplayGroups)));
+
+            // Before starting this task, start an empty service to protect our
+            // process from being reclaimed by the system.
+            context.startService(new Intent(context, EmptyService.class));
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected Void doInBackground(Activity target, AccountSet... params) {
+            final Context context = target;
+            final ContentValues values = new ContentValues();
+            final ContentResolver resolver = context.getContentResolver();
+
+            try {
+                // Build changes and persist in transaction
+                final AccountSet set = params[0];
+                final ArrayList<ContentProviderOperation> diff = set.buildDiff();
+                resolver.applyBatch(ContactsContract.AUTHORITY, diff);
+            } catch (RemoteException e) {
+                Log.e(TAG, "Problem saving display groups", e);
+            } catch (OperationApplicationException e) {
+                Log.e(TAG, "Problem saving display groups", e);
+            }
+
+            return null;
+        }
+
+        /** {@inheritDoc} */
+        @Override
+        protected void onPostExecute(Activity target, Void result) {
+            final Context context = target;
+
+            final ProgressDialog dialog = mProgress.get();
+            if (dialog != null) {
+                try {
+                    dialog.dismiss();
+                } catch (Exception e) {
+                    Log.e(TAG, "Error dismissing progress dialog", e);
+                }
+            }
+
+            target.finish();
+
+            // Stop the service that was protecting us
+            context.stopService(new Intent(context, EmptyService.class));
+        }
+    }
+
+    @Override
+    public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
+            boolean globalSearch) {
+        if (globalSearch) {
+            super.startSearch(initialQuery, selectInitialQuery, appSearchData, globalSearch);
+        } else {
+            ContactsSearchManager.startSearch(this, initialQuery);
+        }
+    }
+}
diff --git a/src/com/android/contacts/list/DefaultContactBrowseListFragment.java b/src/com/android/contacts/list/DefaultContactBrowseListFragment.java
index 3e6dbe5..7e3fb0e 100644
--- a/src/com/android/contacts/list/DefaultContactBrowseListFragment.java
+++ b/src/com/android/contacts/list/DefaultContactBrowseListFragment.java
@@ -17,8 +17,11 @@
 
 import com.android.contacts.R;
 import com.android.contacts.ui.ContactsPreferencesActivity.Prefs;
+import com.android.contacts.widget.NotifyingSpinner;
 
+import android.app.Activity;
 import android.app.LoaderManager.LoaderCallbacks;
+import android.content.Intent;
 import android.content.Loader;
 import android.content.SharedPreferences;
 import android.database.Cursor;
@@ -32,8 +35,6 @@
 import android.widget.AdapterView.OnItemSelectedListener;
 import android.widget.BaseAdapter;
 import android.widget.FrameLayout;
-import android.widget.ImageView;
-import android.widget.Spinner;
 import android.widget.TextView;
 
 import java.util.ArrayList;
@@ -44,13 +45,17 @@
  * picking a contact with one of the PICK intents).
  */
 public class DefaultContactBrowseListFragment extends ContactBrowseListFragment
-        implements OnItemSelectedListener {
+        implements OnItemSelectedListener, NotifyingSpinner.SelectionListener {
 
     private static final String KEY_EDIT_MODE = "editMode";
     private static final String KEY_CREATE_CONTACT_ENABLED = "createContactEnabled";
     private static final String KEY_DISPLAY_WITH_PHONES_ONLY = "displayWithPhonesOnly";
     private static final String KEY_VISIBLE_CONTACTS_RESTRICTION = "visibleContactsRestriction";
 
+    private static final int GROUP_FILTER_LOADER = -4;
+
+    private static final int REQUEST_CODE_CUSTOMIZE_FILTER = 3;
+
     private boolean mEditMode;
     private boolean mCreateContactEnabled;
     private int mDisplayWithPhonesOnlyOption = ContactsRequest.DISPLAY_ONLY_WITH_PHONES_DISABLED;
@@ -61,7 +66,7 @@
     private SparseArray<ContactListFilter> mFilters;
     private ArrayList<ContactListFilter> mFilterList;
     private int mNextFilterId = 1;
-    private Spinner mFilterSpinner;
+    private NotifyingSpinner mFilterSpinner;
     private ContactListFilter mFilter;
     private boolean mFiltersLoaded;
 
@@ -198,7 +203,7 @@
     }
 
     protected void configureFilterSpinner() {
-        mFilterSpinner = (Spinner)getView().findViewById(R.id.filter_spinner);
+        mFilterSpinner = (NotifyingSpinner)getView().findViewById(R.id.filter_spinner);
         if (mFilterSpinner == null) {
             return;
         }
@@ -208,6 +213,7 @@
             return;
         }
         mFilterSpinner.setOnItemSelectedListener(this);
+        mFilterSpinner.setSetSelectionListener(this);
     }
 
     @Override
@@ -355,6 +361,24 @@
         }
     }
 
+    @Override
+    public void onSetSelection(NotifyingSpinner spinner, int position) {
+        ContactListFilter filter = mFilters.valueAt(position);
+        if (filter.filterType == ContactListFilter.FILTER_TYPE_CUSTOM) {
+            startActivityForResult(new Intent(getContext(), CustomContactListFilterActivity.class),
+                    REQUEST_CODE_CUSTOMIZE_FILTER);
+        }
+    }
+
+    @Override
+    public void onActivityResult(int requestCode, int resultCode, Intent data) {
+        if (requestCode == REQUEST_CODE_CUSTOMIZE_FILTER && resultCode == Activity.RESULT_OK) {
+            mFilter = new ContactListFilter(ContactListFilter.FILTER_TYPE_CUSTOM);
+            updateFilterView();
+            reloadData();
+        }
+    }
+
     private ContactListFilter getDefaultFilter() {
         return mFilters.valueAt(0);
     }
@@ -398,55 +422,15 @@
         }
 
         public View getView(int position, View convertView, ViewGroup parent, boolean dropdown) {
-            View view = convertView != null ? convertView
-                    : mLayoutInflater.inflate(R.layout.filter_spinner_item, parent, false);
-            ImageView icon = (ImageView) view.findViewById(R.id.icon);
-            TextView label = (TextView) view.findViewById(R.id.label);
-            TextView indentedLabel = (TextView) view.findViewById(R.id.indented_label);
-            ContactListFilter filter = mFilters.valueAt(position);
-            switch (filter.filterType) {
-                case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: {
-                    icon.setVisibility(View.GONE);
-                    label.setText(R.string.list_filter_all_accounts);
-                    label.setVisibility(View.VISIBLE);
-                    indentedLabel.setVisibility(View.GONE);
-                    break;
-                }
-                case ContactListFilter.FILTER_TYPE_CUSTOM: {
-                    icon.setVisibility(View.GONE);
-                    label.setText(dropdown
-                            ? R.string.list_filter_customize
-                            : R.string.list_filter_custom);
-                    label.setVisibility(View.VISIBLE);
-                    indentedLabel.setVisibility(View.GONE);
-                    break;
-                }
-                case ContactListFilter.FILTER_TYPE_ACCOUNT: {
-                    icon.setVisibility(View.VISIBLE);
-                    if (filter.icon != null) {
-                        icon.setImageDrawable(filter.icon);
-                    } else {
-                        icon.setImageResource(R.drawable.unknown_source);
-                    }
-                    label.setText(filter.accountName);
-                    label.setVisibility(View.VISIBLE);
-                    indentedLabel.setVisibility(View.GONE);
-                    break;
-                }
-                case ContactListFilter.FILTER_TYPE_GROUP: {
-                    icon.setVisibility(View.GONE);
-                    if (dropdown) {
-                        label.setVisibility(View.GONE);
-                        indentedLabel.setText(filter.title);
-                        indentedLabel.setVisibility(View.VISIBLE);
-                    } else {
-                        label.setText(filter.title);
-                        label.setVisibility(View.VISIBLE);
-                        indentedLabel.setVisibility(View.GONE);
-                    }
-                    break;
-                }
+            FilterSpinnerItemView view;
+            if (dropdown && convertView != null) {
+                view = (FilterSpinnerItemView) convertView;
+            } else {
+                view = (FilterSpinnerItemView) mLayoutInflater.inflate(
+                        R.layout.filter_spinner_item, parent, false);
             }
+            view.setContactListFilter(mFilters.valueAt(position));
+            view.bindView(dropdown);
             return view;
         }
     }
diff --git a/src/com/android/contacts/list/FilterSpinnerItemView.java b/src/com/android/contacts/list/FilterSpinnerItemView.java
new file mode 100644
index 0000000..ab98a41
--- /dev/null
+++ b/src/com/android/contacts/list/FilterSpinnerItemView.java
@@ -0,0 +1,105 @@
+/*
+ * Copyright (C) 2010 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.contacts.list;
+
+import com.android.contacts.R;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.view.View;
+import android.widget.ImageView;
+import android.widget.LinearLayout;
+import android.widget.TextView;
+
+/**
+ * Contact list filter parameters.
+ */
+public final class FilterSpinnerItemView extends LinearLayout {
+
+    private ImageView mIcon;
+    private TextView mLabel;
+    private TextView mIndentedLabel;
+    private ContactListFilter mFilter;
+
+    public FilterSpinnerItemView(Context context) {
+        super(context);
+    }
+
+    public FilterSpinnerItemView(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public void setContactListFilter(ContactListFilter filter) {
+        mFilter = filter;
+    }
+
+    public ContactListFilter getContactListFilter() {
+        return mFilter;
+    }
+
+    public void bindView(boolean dropdown) {
+        if (mIcon == null) {
+            mIcon = (ImageView) findViewById(R.id.icon);
+            mLabel = (TextView) findViewById(R.id.label);
+            mIndentedLabel = (TextView) findViewById(R.id.indented_label);
+        }
+
+        switch (mFilter.filterType) {
+            case ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS: {
+                mIcon.setVisibility(View.GONE);
+                mLabel.setText(R.string.list_filter_all_accounts);
+                mLabel.setVisibility(View.VISIBLE);
+                mIndentedLabel.setVisibility(View.GONE);
+                break;
+            }
+            case ContactListFilter.FILTER_TYPE_CUSTOM: {
+                mIcon.setVisibility(View.GONE);
+                mLabel.setText(dropdown
+                        ? R.string.list_filter_customize
+                        : R.string.list_filter_custom);
+                mLabel.setVisibility(View.VISIBLE);
+                mIndentedLabel.setVisibility(View.GONE);
+                break;
+            }
+            case ContactListFilter.FILTER_TYPE_ACCOUNT: {
+                mIcon.setVisibility(View.VISIBLE);
+                if (mFilter.icon != null) {
+                    mIcon.setImageDrawable(mFilter.icon);
+                } else {
+                    mIcon.setImageResource(R.drawable.unknown_source);
+                }
+                mLabel.setText(mFilter.accountName);
+                mLabel.setVisibility(View.VISIBLE);
+                mIndentedLabel.setVisibility(View.GONE);
+                break;
+            }
+            case ContactListFilter.FILTER_TYPE_GROUP: {
+                mIcon.setVisibility(View.GONE);
+                if (dropdown) {
+                    mLabel.setVisibility(View.GONE);
+                    mIndentedLabel.setText(mFilter.title);
+                    mIndentedLabel.setVisibility(View.VISIBLE);
+                } else {
+                    mLabel.setText(mFilter.title);
+                    mLabel.setVisibility(View.VISIBLE);
+                    mIndentedLabel.setVisibility(View.GONE);
+                }
+                break;
+            }
+        }
+    }
+}
diff --git a/src/com/android/contacts/widget/NotifyingSpinner.java b/src/com/android/contacts/widget/NotifyingSpinner.java
new file mode 100644
index 0000000..972cb35
--- /dev/null
+++ b/src/com/android/contacts/widget/NotifyingSpinner.java
@@ -0,0 +1,55 @@
+/*
+ * Copyright (C) 2010 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.contacts.widget;
+
+import android.content.Context;
+import android.util.AttributeSet;
+import android.widget.Spinner;
+
+/**
+ * Spinner that notifies a listener when the user taps on an item, whether or not this results
+ * in a change of selection.
+ */
+public class NotifyingSpinner extends Spinner {
+
+    public interface SelectionListener {
+        void onSetSelection(NotifyingSpinner view, int position);
+    }
+
+    private SelectionListener mListener;
+
+    public NotifyingSpinner(Context context) {
+        super(context);
+    }
+
+    public NotifyingSpinner(Context context, AttributeSet attrs) {
+        super(context, attrs);
+    }
+
+    public void setSetSelectionListener(SelectionListener listener) {
+        mListener = listener;
+    }
+
+    @Override
+    public void setSelection(int position) {
+        super.setSelection(position);
+
+        if (mListener != null) {
+            mListener.onSetSelection(this, position);
+        }
+    }
+}