Rewrite display groups to persist changes in transaction.

In a previous change we relied on now-removed query
parameters to delay visibility updates until forced through
a specific update.  This would leave the contacts list in
a stale state when the user left the Activity through
non-typical methods, such as notification or home key.

This change keeps all user edits in memory, reusing code
from our edit UI, and persisting them in as a single
transaction when finished.  This also adds a button bar
to help confirm or revert changes.  Fixes http://b/2075275

In addition, this change cleans up inefficient code where
we had been previously joining together up to 3 cursors for
each account to match a given UI spec.
diff --git a/res/layout/act_display_groups.xml b/res/layout/act_display_groups.xml
new file mode 100644
index 0000000..5ee93e7
--- /dev/null
+++ b/res/layout/act_display_groups.xml
@@ -0,0 +1,52 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Copyright (C) 2007 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:layout_width="fill_parent"
+    android:layout_height="fill_parent"
+    android:orientation="vertical"
+    android:fillViewport="true">
+
+    <ExpandableListView
+        android:id="@android:id/list"
+        android:layout_width="fill_parent"
+        android:layout_height="0dip"
+        android:layout_weight="1" />
+
+    <LinearLayout
+        android:layout_width="fill_parent"
+        android:layout_height="wrap_content"
+        android:orientation="horizontal"
+        style="@android:style/ButtonBar">
+
+        <Button
+            android:id="@+id/btn_done"
+            android:layout_width="0dip"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="@string/menu_done" />
+
+        <Button
+            android:id="@+id/btn_discard"
+            android:layout_width="0dip"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="@string/menu_doNotSave" />
+
+    </LinearLayout>
+
+</LinearLayout>
diff --git a/src/com/android/contacts/model/EntityDelta.java b/src/com/android/contacts/model/EntityDelta.java
index 06c10d7..f95bc12 100644
--- a/src/com/android/contacts/model/EntityDelta.java
+++ b/src/com/android/contacts/model/EntityDelta.java
@@ -16,8 +16,6 @@
 
 package com.android.contacts.model;
 
-import com.android.contacts.model.ContactsSource.DataKind;
-import com.android.contacts.model.ContactsSource.EditField;
 import com.google.android.collect.Lists;
 import com.google.android.collect.Maps;
 import com.google.android.collect.Sets;
@@ -34,7 +32,6 @@
 import android.provider.ContactsContract;
 import android.provider.ContactsContract.Data;
 import android.provider.ContactsContract.RawContacts;
-import android.text.TextUtils;
 import android.util.Log;
 import android.view.View;
 
@@ -437,9 +434,9 @@
      * or delete operations based on a "before" {@link Entity} snapshot.
      */
     public static class ValuesDelta implements Parcelable {
-        private ContentValues mBefore;
-        private ContentValues mAfter;
-        private String mIdColumn = BaseColumns._ID;
+        protected ContentValues mBefore;
+        protected ContentValues mAfter;
+        protected String mIdColumn = BaseColumns._ID;
 
         /**
          * Next value to assign to {@link #mIdColumn} when building an insert
@@ -447,9 +444,9 @@
          * we can concretely reference this {@link ValuesDelta} before it has
          * been persisted.
          */
-        private static int sNextInsertId = -1;
+        protected static int sNextInsertId = -1;
 
-        private ValuesDelta() {
+        protected ValuesDelta() {
         }
 
         /**
@@ -507,6 +504,20 @@
             }
         }
 
+        public Integer getAsInteger(String key) {
+            return getAsInteger(key, null);
+        }
+
+        public Integer getAsInteger(String key, Integer defaultValue) {
+            if (mAfter != null && mAfter.containsKey(key)) {
+                return mAfter.getAsInteger(key);
+            } else if (mBefore != null && mBefore.containsKey(key)) {
+                return mBefore.getAsInteger(key);
+            } else {
+                return defaultValue;
+            }
+        }
+
         public String getMimetype() {
             return getAsString(Data.MIMETYPE);
         }
@@ -551,6 +562,11 @@
             return beforeExists() && (mAfter != null && mAfter.size() > 0);
         }
 
+        public boolean isNoop() {
+            // When "after" has no changes, action is no-op
+            return beforeExists() && (mAfter != null && mAfter.size() == 0);
+        }
+
         public boolean isInsert() {
             // When no "before" id, and has "after", action is "insert"
             return !beforeExists() && (mAfter != null);
diff --git a/src/com/android/contacts/ui/DisplayGroupsActivity.java b/src/com/android/contacts/ui/DisplayGroupsActivity.java
index 69f0007..dcbe0f6 100644
--- a/src/com/android/contacts/ui/DisplayGroupsActivity.java
+++ b/src/com/android/contacts/ui/DisplayGroupsActivity.java
@@ -17,38 +17,39 @@
 package com.android.contacts.ui;
 
 import com.android.contacts.R;
-import com.android.contacts.model.GoogleSource;
 import com.android.contacts.model.ContactsSource;
+import com.android.contacts.model.GoogleSource;
 import com.android.contacts.model.Sources;
+import com.android.contacts.model.EntityDelta.ValuesDelta;
 import com.android.contacts.util.EmptyService;
 import com.android.contacts.util.WeakAsyncTask;
-import com.google.android.collect.Sets;
+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.ContentResolver;
-import android.content.ContentUris;
 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.ContentProviderOperation.Builder;
 import android.content.SharedPreferences.Editor;
-import android.content.pm.PackageManager;
-import android.database.AbstractCursor;
 import android.database.Cursor;
-import android.database.CursorWrapper;
-import android.database.MergeCursor;
 import android.net.Uri;
 import android.os.Bundle;
+import android.os.RemoteException;
 import android.preference.PreferenceManager;
 import android.provider.ContactsContract;
-import android.provider.ContactsContract.Contacts;
 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;
@@ -56,8 +57,8 @@
 import android.view.ViewGroup;
 import android.view.MenuItem.OnMenuItemClickListener;
 import android.widget.AdapterView;
+import android.widget.BaseExpandableListAdapter;
 import android.widget.CheckBox;
-import android.widget.CursorTreeAdapter;
 import android.widget.ExpandableListAdapter;
 import android.widget.ExpandableListView;
 import android.widget.TextView;
@@ -65,21 +66,18 @@
 
 import java.lang.ref.WeakReference;
 import java.util.ArrayList;
-import java.util.HashSet;
+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 DisplayGroupsActivity extends ExpandableListActivity implements
-        AdapterView.OnItemClickListener {
+        AdapterView.OnItemClickListener, View.OnClickListener {
     private static final String TAG = "DisplayGroupsActivity";
 
-    private static final int UNGROUPED_ID = -2;
-    private static final int UNSYNCED_ID = -3;
-
-    private static final int FOOTER_ENTRY = -4;
-
     public interface Prefs {
         public static final String DISPLAY_ONLY_PHONES = "only_phones";
         public static final boolean DISPLAY_ONLY_PHONES_DEFAULT = false;
@@ -87,7 +85,7 @@
     }
 
     private ExpandableListView mList;
-    private DisplayGroupsAdapter mAdapter;
+    private DisplayAdapter mAdapter;
 
     private SharedPreferences mPrefs;
 
@@ -96,16 +94,10 @@
     private View mHeaderPhones;
     private View mHeaderSeparator;
 
-    private static final Uri sDelayedSettings = Settings.CONTENT_URI.buildUpon()
-            .appendQueryParameter(Contacts.DELAY_STARRED_UPDATE, "1").build();
-
-    private static final Uri sDelayedGroups = Groups.CONTENT_URI.buildUpon()
-            .appendQueryParameter(Contacts.DELAY_STARRED_UPDATE, "1").build();
-
     @Override
     protected void onCreate(Bundle icicle) {
         super.onCreate(icicle);
-        setContentView(android.R.layout.expandable_list_content);
+        setContentView(R.layout.act_display_groups);
 
         mList = getExpandableListView();
         mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
@@ -116,6 +108,8 @@
         mHeaderPhones = inflater.inflate(R.layout.display_header, mList, false);
         mHeaderPhones.setId(R.id.header_phones);
         mDisplayPhones = (CheckBox) mHeaderPhones.findViewById(android.R.id.checkbox);
+        mDisplayPhones.setChecked(mPrefs.getBoolean(Prefs.DISPLAY_ONLY_PHONES,
+                Prefs.DISPLAY_ONLY_PHONES_DEFAULT));
         {
             final TextView text1 = (TextView)mHeaderPhones.findViewById(android.R.id.text1);
             final TextView text2 = (TextView)mHeaderPhones.findViewById(android.R.id.text2);
@@ -124,7 +118,6 @@
         }
         mList.addHeaderView(mHeaderPhones, null, true);
 
-
         // Add the separator before showing the detailed group list.
         mHeaderSeparator = inflater.inflate(R.layout.list_separator, mList, false);
         {
@@ -133,91 +126,475 @@
         }
         mList.addHeaderView(mHeaderSeparator, null, false);
 
-        mAdapter = new DisplayGroupsAdapter(null, this, this);
-
-        boolean displayOnlyPhones = mPrefs.getBoolean(Prefs.DISPLAY_ONLY_PHONES,
-                Prefs.DISPLAY_ONLY_PHONES_DEFAULT);
-
-        mDisplayPhones.setChecked(displayOnlyPhones);
-
-        mAdapter.setChildDescripWithPhones(displayOnlyPhones);
-
-        setListAdapter(mAdapter);
+        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);
 
         // Start background query to find account details
-        new QuerySettingsTask(this).execute();
+        new QueryGroupsTask(this).execute();
     }
 
-    private static class QuerySettingsTask extends
-            WeakAsyncTask<Void, Void, Cursor, DisplayGroupsActivity> {
-        public QuerySettingsTask(DisplayGroupsActivity target) {
+    /**
+     * 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, DisplayGroupsActivity> {
+        public QueryGroupsTask(DisplayGroupsActivity target) {
             super(target);
         }
 
         @Override
-        protected Cursor doInBackground(DisplayGroupsActivity target, Void... params) {
+        protected AccountSet doInBackground(DisplayGroupsActivity target,
+                Void... params) {
             final Context context = target;
             final Sources sources = Sources.getInstance(context);
-
-            // Query to find Settings for all data sources
             final ContentResolver resolver = context.getContentResolver();
-            final Cursor cursor = resolver.query(ContactsContract.Settings.CONTENT_URI,
-                    SettingsQuery.PROJECTION, null, null, null);
-            target.startManagingCursor(cursor);
 
-            // Make records for each account known by Settings
-            final HashSet<Account> knownAccounts = Sets.newHashSet();
-            while (cursor.moveToNext()) {
-                final String accountName = cursor.getString(SettingsQuery.ACCOUNT_NAME);
-                final String accountType = cursor.getString(SettingsQuery.ACCOUNT_TYPE);
-                final Account account = new Account(accountName, accountType);
-                knownAccounts.add(account);
+            // 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));
             }
 
-            // Assert that Settings exist for each data source
-            boolean changedSettings = false;
-            final ArrayList<Account> expectedAccounts = sources.getAccounts(false);
-            for (Account account : expectedAccounts) {
-                if (!knownAccounts.contains(account)) {
-                    // Expected account that doesn't exist yet in Settings
-                    final ContentValues values = new ContentValues();
-                    values.put(Settings.ACCOUNT_NAME, account.name);
-                    values.put(Settings.ACCOUNT_TYPE, account.type);
-                    resolver.insert(Settings.CONTENT_URI, values);
-
-                    // Make sure we requery to catch this insert
-                    changedSettings = true;
-                }
-            }
-
-            if (changedSettings) {
-                // Catch any new sources discovered above
-                cursor.requery();
-            }
-
-            // Wrap cursor to provide _id column
-            final Cursor settingsCursor = new CursorWrapper(cursor) {
-                @Override
-                public long getLong(int columnIndex) {
-                    if (columnIndex == -1) {
-                        return this.getPosition();
-                    } else {
-                        return super.getLong(columnIndex);
-                    }
-                }
-            };
-
-            return settingsCursor;
+            return accounts;
         }
 
         @Override
-        protected void onPostExecute(DisplayGroupsActivity target, Cursor result) {
-            // Update cursor for data sources
-            target.mAdapter.setGroupCursor(result);
+        protected void onPostExecute(DisplayGroupsActivity target, AccountSet result) {
+            // Build adapter to show available groups
+            final Context context = target;
+            final DisplayAdapter adapter = new DisplayAdapter(context, result);
+            target.setListAdapter(adapter);
+        }
+    }
+
+    public void setListAdapter(DisplayAdapter adapter) {
+        mAdapter = adapter;
+        mAdapter.setChildDescripWithPhones(mDisplayPhones.isChecked());
+        super.setListAdapter(mAdapter);
+    }
+
+    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 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) {
+            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();
+                } 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();
+                }
+            } 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() {
+            mUngrouped = true;
+            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);
+        }
+
+        public CharSequence getTitle(Context context) {
+            if (mUngrouped) {
+                return context.getText(R.string.display_ungrouped);
+            } 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 : 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");
+            }
+        }
+    }
+
+    /**
+     * {@link Comparator} to sort by {@link Groups#_ID}.
+     */
+    private static Comparator<GroupDelta> sIdComparator = new Comparator<GroupDelta>() {
+        public int compare(GroupDelta object1, GroupDelta object2) {
+            return object1.getViewId() - object2.getViewId();
+        }
+    };
+
+    /**
+     * 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;
+
+            // Create single entry handling ungrouped status
+            mUngrouped = GroupDelta.fromSettings(resolver, accountName, accountType);
+            addGroup(mUngrouped);
+
+            final Uri groupsUri = Groups.CONTENT_URI.buildUpon()
+                    .appendQueryParameter(Groups.ACCOUNT_NAME, accountName)
+                    .appendQueryParameter(Groups.ACCOUNT_TYPE, accountType).build();
+            EntityIterator iterator = null;
+            try {
+                // Create entries for each known group
+                iterator = resolver.queryEntities(groupsUri, null, null, null);
+                while (iterator.hasNext()) {
+                    final ContentValues values = iterator.next().getEntityValues();
+                    final GroupDelta group = GroupDelta.fromBefore(values);
+                    addGroup(group);
+                }
+            } catch (RemoteException e) {
+                Log.w(TAG, "Problem reading groups: " + e.toString());
+            } finally {
+                if (iterator != null) 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, AccountSet accounts) {
+            mContext = context;
+            mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
+            mSources = Sources.getInstance(context);
+
+            mAccounts = accounts;
+        }
+
+        /**
+         * 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() {
+            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;
         }
     }
 
@@ -229,7 +606,20 @@
         switch (view.getId()) {
             case R.id.header_phones: {
                 mDisplayPhones.toggle();
-                setDisplayOnlyPhones(mDisplayPhones.isChecked());
+                break;
+            }
+        }
+    }
+
+    /** {@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;
             }
         }
@@ -255,36 +645,19 @@
      * usually mean toggling its visible state.
      */
     @Override
-    public boolean onChildClick(ExpandableListView parent, View v, int groupPosition,
+    public boolean onChildClick(ExpandableListView parent, View view, int groupPosition,
             int childPosition, long id) {
-        final CheckBox checkbox = (CheckBox)v.findViewById(android.R.id.checkbox);
-        checkbox.toggle();
+        final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox);
 
-        // Build visibility update and send down to database
-        final ContentResolver resolver = getContentResolver();
-        final ContentValues values = new ContentValues();
-
-        if (id == UNGROUPED_ID) {
-            // Handle persisting for ungrouped through Settings
-            values.put(Settings.UNGROUPED_VISIBLE, checkbox.isChecked() ? 1 : 0);
-
-            final Cursor settings = mAdapter.getGroup(groupPosition);
-            final int count = resolver.update(sDelayedSettings, values, Groups.ACCOUNT_NAME
-                    + "=? AND " + Groups.ACCOUNT_TYPE + "=?", new String[] {
-                    settings.getString(SettingsQuery.ACCOUNT_NAME),
-                    settings.getString(SettingsQuery.ACCOUNT_TYPE)
-            });
-        } else if (id == UNSYNCED_ID) {
-            // Open context menu for bringing back unsynced
-            this.openContextMenu(v);
+        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 {
-            // Handle persisting for normal group
-            values.put(Groups.GROUP_VISIBLE, checkbox.isChecked() ? 1 : 0);
-
-            final Uri groupUri = ContentUris.withAppendedId(sDelayedGroups, id);
-            final int count = resolver.update(groupUri, values, null, null);
+            // Open context menu for bringing back unsynced
+            this.openContextMenu(view);
         }
-
         return true;
     }
 
@@ -294,9 +667,19 @@
     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 v, ContextMenu.ContextMenuInfo menuInfo) {
-        super.onCreateContextMenu(menu, v, menuInfo);
+    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;
@@ -305,54 +688,42 @@
         final int groupPosition = ExpandableListView.getPackedPositionGroup(info.packedPosition);
         final int childPosition = ExpandableListView.getPackedPositionChild(info.packedPosition);
 
-        final Cursor groupCursor = mAdapter.getGroup(groupPosition);
-        final String accountName = groupCursor.getString(SettingsQuery.ACCOUNT_NAME);
-        final String accountType = groupCursor.getString(SettingsQuery.ACCOUNT_TYPE);
+        // Skip long-press on expandable parents
+        if (childPosition == -1) return;
 
-        // TODO: read sync mode through <sync-adapter> definition
-        int syncMode = SYNC_MODE_UNSUPPORTED;
-	if (accountType.equals(GoogleSource.ACCOUNT_TYPE)) {
-	    syncMode = SYNC_MODE_EVERYTHING;
-	}
+        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;
 
-        final Account account = new Account(accountName, accountType);
-
-        final boolean shouldSyncUngrouped = groupCursor.getInt(SettingsQuery.SHOULD_SYNC) != 0;
-        final boolean anyUnsynced = groupCursor.getInt(SettingsQuery.ANY_UNSYNCED) != 0;
-        final boolean lastChild = (childPosition == (mAdapter.getChildrenCount(groupPosition) - 1));
-
-        if (anyUnsynced && lastChild) {
-            // Show add dialog for this overall source
-            showAddSync(menu, groupCursor, account, syncMode);
-
-        } else if (childPosition != -1) {
-            // Show remove dialog for this specific group
-            final Cursor childCursor = mAdapter.getChild(groupPosition, childPosition);
-            showRemoveSync(menu, account, childCursor, syncMode, shouldSyncUngrouped);
+        if (child != null) {
+            showRemoveSync(menu, account, child, syncMode);
+        } else {
+            showAddSync(menu, account, syncMode);
         }
     }
 
-    protected void showRemoveSync(ContextMenu menu, final Account account, Cursor childCursor,
-            final int syncMode, final boolean shouldSyncUngrouped) {
-        final long groupId = childCursor.getLong(GroupsQuery._ID);
-        final CharSequence title = getGroupTitle(this, childCursor);
+    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(groupId, account, syncMode, title, shouldSyncUngrouped);
+                        handleRemoveSync(account, child, syncMode, title);
                         return true;
                     }
                 });
     }
 
-    protected void handleRemoveSync(final long groupId, final Account account, final int syncMode,
-            CharSequence title, boolean shouldSyncUngrouped) {
-        if (syncMode == SYNC_MODE_EVERYTHING && groupId != UNGROUPED_ID && shouldSyncUngrouped) {
+    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(
@@ -362,96 +733,38 @@
             builder.setNegativeButton(android.R.string.cancel, null);
             builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
                 public void onClick(DialogInterface dialog, int which) {
-                    // Mark this group to not sync
-                    setGroupShouldSync(groupId, account, syncMode, false);
+                    // 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
-            setGroupShouldSync(groupId, account, syncMode, false);
+            account.setShouldSync(child, false);
+            mAdapter.notifyDataSetChanged();
         }
     }
 
-    protected void showAddSync(ContextMenu menu, Cursor groupCursor, final Account account,
-            final int syncMode) {
+    protected void showAddSync(ContextMenu menu, final AccountDisplay account, final int syncMode) {
         menu.setHeaderTitle(R.string.dialog_sync_add);
 
-        // Create single "Ungrouped" item when not synced
-        final boolean ungroupedAvailable = groupCursor.getInt(SettingsQuery.SHOULD_SYNC) == 0;
-        if (ungroupedAvailable) {
-            menu.add(R.string.display_ungrouped).setOnMenuItemClickListener(
-                    new OnMenuItemClickListener() {
-                        public boolean onMenuItemClick(MenuItem item) {
-                            // Adding specific group for syncing
-                            setGroupShouldSync(UNGROUPED_ID, account, syncMode, true);
-                            return true;
-                        }
-                    });
-        }
-
         // Create item for each available, unsynced group
-        final Cursor availableGroups = this.managedQuery(Groups.CONTENT_SUMMARY_URI,
-                GroupsQuery.PROJECTION, Groups.SHOULD_SYNC + "=0 AND " + Groups.ACCOUNT_NAME
-                        + "=? AND " + Groups.ACCOUNT_TYPE + "=?", new String[] {
-                        groupCursor.getString(SettingsQuery.ACCOUNT_NAME),
-                        groupCursor.getString(SettingsQuery.ACCOUNT_TYPE)
-                }, null);
-        while (availableGroups.moveToNext()) {
-            // Create item this unsynced group
-            final long groupId = availableGroups.getLong(GroupsQuery._ID);
-            final CharSequence title = getGroupTitle(this, availableGroups);
-            menu.add(title).setOnMenuItemClickListener(new OnMenuItemClickListener() {
-                public boolean onMenuItemClick(MenuItem item) {
-                    // Adding specific group for syncing
-                    setGroupShouldSync(groupId, account, syncMode, true);
-                    return true;
-                }
-            });
-        }
-    }
-
-    /**
-     * Mark the {@link Groups#SHOULD_SYNC} state of the given group.
-     */
-    protected void setGroupShouldSync(long groupId, Account account, int syncMode, boolean shouldSync) {
-        final ContentResolver resolver = getContentResolver();
-        final ContentValues values = new ContentValues();
-
-        if (syncMode == SYNC_MODE_UNSUPPORTED) {
-            // Ignore changes when source doesn't support syncing
-            return;
-        }
-
-        if (groupId == UNGROUPED_ID) {
-            // Updating the overall syncing flag for this account
-            values.put(Settings.SHOULD_SYNC, shouldSync ? 1 : 0);
-            resolver.update(sDelayedSettings, values, Settings.ACCOUNT_NAME + "=? AND "
-                    + Settings.ACCOUNT_TYPE + "=?", new String[] {
-                    account.name, account.type
-            });
-
-            if (syncMode == SYNC_MODE_EVERYTHING && shouldSync) {
-                // If syncing mode is everything, force-enable all children groups
-                values.clear();
-                values.put(Groups.SHOULD_SYNC, shouldSync ? 1 : 0);
-                resolver.update(sDelayedGroups, values, Groups.ACCOUNT_NAME + "=? AND "
-                        + Groups.ACCOUNT_TYPE + "=?", new String[] {
-                        account.name, account.type
-                });
-            }
-        } else {
-            // Treat as normal group
-            values.put(Groups.SHOULD_SYNC, shouldSync ? 1 : 0);
-            resolver.update(sDelayedGroups, values, Groups._ID + "=" + groupId, null);
-
-            if (syncMode == SYNC_MODE_EVERYTHING && !shouldSync) {
-                // Remove "everything" from sync, user has already been warned
-                values.clear();
-                values.put(Settings.SHOULD_SYNC, shouldSync ? 1 : 0);
-                resolver.update(sDelayedSettings, values, Settings.ACCOUNT_NAME + "=? AND "
-                        + Settings.ACCOUNT_TYPE + "=?", new String[] {
-                        account.name, account.type
+        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;
+                    }
                 });
             }
         }
@@ -460,17 +773,21 @@
     /** {@inheritDoc} */
     @Override
     public void onBackPressed() {
-        // TODO: somehow update visibility when user leaves through a different
-        // path, never actually pressing the back key
-        new UpdateTask(this).execute();
+        doSaveAction();
+    }
+
+    private void doSaveAction() {
+        if (mAdapter == null) return;
+        setDisplayOnlyPhones(mDisplayPhones.isChecked());
+        new UpdateTask(this).execute(mAdapter.mAccounts);
     }
 
     /**
-     * Background task that uses {@link Contacts#FORCE_STARRED_UPDATE} to force
-     * update of {@link Contacts#IN_VISIBLE_GROUP}, showing spinner dialog to
-     * user while updating.
+     * Background task that persists changes to {@link Groups#GROUP_VISIBLE},
+     * showing spinner dialog to user while updating.
      */
-    public static class UpdateTask extends WeakAsyncTask<Void, Void, Void, Activity> {
+    public static class UpdateTask extends
+            WeakAsyncTask<AccountSet, Void, Void, Activity> {
         private WeakReference<ProgressDialog> mProgress;
 
         public UpdateTask(Activity target) {
@@ -482,8 +799,8 @@
         protected void onPreExecute(Activity target) {
             final Context context = target;
 
-            mProgress = new WeakReference<ProgressDialog>(ProgressDialog.show(target, null,
-                    target.getText(R.string.savingDisplayGroups)));
+            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.
@@ -492,16 +809,21 @@
 
         /** {@inheritDoc} */
         @Override
-        protected Void doInBackground(Activity target, Void... params) {
+        protected Void doInBackground(Activity target, AccountSet... params) {
             final Context context = target;
-
             final ContentValues values = new ContentValues();
             final ContentResolver resolver = context.getContentResolver();
 
-            // Push through an empty update to trigger forced refresh
-            final Uri forcedGroups = Groups.CONTENT_URI.buildUpon().appendQueryParameter(
-                    Contacts.FORCE_STARRED_UPDATE, "1").build();
-            resolver.update(forcedGroups, values, null, null);
+            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;
         }
@@ -520,358 +842,4 @@
             context.stopService(new Intent(context, EmptyService.class));
         }
     }
-
-
-    /**
-     * Return the best title for the {@link Groups} entry at the current
-     * {@link Cursor} position.
-     */
-    protected static CharSequence getGroupTitle(Context context, Cursor cursor) {
-        final PackageManager pm = context.getPackageManager();
-        if (!cursor.isNull(GroupsQuery.TITLE_RES)) {
-            final String packageName = cursor.getString(GroupsQuery.RES_PACKAGE);
-            final int titleRes = cursor.getInt(GroupsQuery.TITLE_RES);
-            return pm.getText(packageName, titleRes, null);
-        } else {
-            return cursor.getString(GroupsQuery.TITLE);
-        }
-    }
-
-    /**
-     * Special {@link Cursor} that shows zero or one items based on
-     * {@link Settings#SHOULD_SYNC} value. This header only supports
-     * {@link #SYNC_MODE_UNGROUPED} and {@link #SYNC_MODE_UNSUPPORTED}.
-     */
-    private static class HeaderCursor extends AbstractCursor {
-        private Context mContext;
-        private Cursor mCursor;
-        private int mPosition;
-
-        public HeaderCursor(Context context, Cursor cursor, int position) {
-            mContext = context;
-            mCursor = cursor;
-            mPosition = position;
-        }
-
-        @Override
-        public int getCount() {
-            assertParent();
-
-            final boolean shouldSync = mCursor.getInt(SettingsQuery.SHOULD_SYNC) != 0;
-            return shouldSync ? 1 : 0;
-        }
-
-        @Override
-        public String[] getColumnNames() {
-            return GroupsQuery.PROJECTION;
-        }
-
-        protected void assertParent() {
-            mCursor.moveToPosition(mPosition);
-        }
-
-        @Override
-        public String getString(int column) {
-            assertParent();
-            switch(column) {
-                case GroupsQuery.ACCOUNT_NAME:
-                    return mCursor.getString(SettingsQuery.ACCOUNT_NAME);
-                case GroupsQuery.ACCOUNT_TYPE:
-                    return mCursor.getString(SettingsQuery.ACCOUNT_TYPE);
-                case GroupsQuery.TITLE:
-                    return null;
-                case GroupsQuery.RES_PACKAGE:
-                    return mContext.getPackageName();
-                case GroupsQuery.TITLE_RES:
-                    return Integer.toString(UNGROUPED_ID);
-            }
-            throw new IllegalArgumentException("Requested column not available as string");
-        }
-
-        @Override
-        public short getShort(int column) {
-            throw new IllegalArgumentException("Requested column not available as short");
-        }
-
-        @Override
-        public int getInt(int column) {
-            assertParent();
-            switch(column) {
-                case GroupsQuery._ID:
-                    return UNGROUPED_ID;
-                case GroupsQuery.TITLE_RES:
-                    return R.string.display_ungrouped;
-                case GroupsQuery.GROUP_VISIBLE:
-                    return mCursor.getInt(SettingsQuery.UNGROUPED_VISIBLE);
-//                case GroupsQuery.SUMMARY_COUNT:
-//                    return mCursor.getInt(SettingsQuery.UNGROUPED_COUNT);
-//                case GroupsQuery.SUMMARY_WITH_PHONES:
-//                    return mCursor.getInt(SettingsQuery.UNGROUPED_WITH_PHONES);
-            }
-            throw new IllegalArgumentException("Requested column not available as int");
-        }
-
-        @Override
-        public long getLong(int column) {
-            return getInt(column);
-        }
-
-        @Override
-        public float getFloat(int column) {
-            throw new IllegalArgumentException("Requested column not available as float");
-        }
-
-        @Override
-        public double getDouble(int column) {
-            throw new IllegalArgumentException("Requested column not available as double");
-        }
-
-        @Override
-        public boolean isNull(int column) {
-            return getString(column) == null;
-        }
-    }
-
-    /**
-     * Special {@link Cursor} that shows zero or one items based on
-     * {@link Settings#ANY_UNSYNCED} value.
-     */
-    private static class FooterCursor extends AbstractCursor {
-        private Context mContext;
-        private Cursor mCursor;
-        private int mPosition;
-
-        public FooterCursor(Context context, Cursor cursor, int position) {
-            mContext = context;
-            mCursor = cursor;
-            mPosition = position;
-        }
-
-        @Override
-        public int getCount() {
-            assertParent();
-
-            final boolean anyUnsynced = mCursor.getInt(SettingsQuery.ANY_UNSYNCED) != 0;
-            return anyUnsynced ? 1 : 0;
-        }
-
-        @Override
-        public String[] getColumnNames() {
-            return GroupsQuery.PROJECTION;
-        }
-
-        protected void assertParent() {
-            mCursor.moveToPosition(mPosition);
-        }
-
-        @Override
-        public String getString(int column) {
-            assertParent();
-            switch(column) {
-                case GroupsQuery.ACCOUNT_NAME:
-                    return mCursor.getString(SettingsQuery.ACCOUNT_NAME);
-                case GroupsQuery.ACCOUNT_TYPE:
-                    return mCursor.getString(SettingsQuery.ACCOUNT_TYPE);
-                case GroupsQuery.TITLE:
-                    return null;
-                case GroupsQuery.RES_PACKAGE:
-                    return mContext.getPackageName();
-                case GroupsQuery.TITLE_RES:
-                    return Integer.toString(UNSYNCED_ID);
-            }
-            throw new IllegalArgumentException("Requested column not available as string");
-        }
-
-        @Override
-        public short getShort(int column) {
-            throw new IllegalArgumentException("Requested column not available as short");
-        }
-
-        @Override
-        public int getInt(int column) {
-            assertParent();
-            switch(column) {
-                case GroupsQuery._ID:
-                    return UNSYNCED_ID;
-                case GroupsQuery.TITLE_RES:
-                    return R.string.display_more_groups;
-                case GroupsQuery.GROUP_VISIBLE:
-                case GroupsQuery.SUMMARY_COUNT:
-                case GroupsQuery.SUMMARY_WITH_PHONES:
-                    return FOOTER_ENTRY;
-            }
-            throw new IllegalArgumentException("Requested column not available as int");
-        }
-
-        @Override
-        public long getLong(int column) {
-            return getInt(column);
-        }
-
-        @Override
-        public float getFloat(int column) {
-            throw new IllegalArgumentException("Requested column not available as float");
-        }
-
-        @Override
-        public double getDouble(int column) {
-            throw new IllegalArgumentException("Requested column not available as double");
-        }
-
-        @Override
-        public boolean isNull(int column) {
-            return getString(column) == null;
-        }
-    }
-
-    /**
-     * Adapter that shows all display groups as returned by a {@link Cursor}
-     * over {@link Groups#CONTENT_SUMMARY_URI}, along with their current visible
-     * status. Splits groups into sections based on {@link Account}.
-     */
-    private static class DisplayGroupsAdapter extends CursorTreeAdapter {
-        private Context mContext;
-        private Activity mActivity;
-        private LayoutInflater mInflater;
-        private Sources mSources;
-
-        private boolean mChildWithPhones = false;
-
-        public DisplayGroupsAdapter(Cursor cursor, Context context, Activity activity) {
-            super(cursor, context, true);
-
-            mContext = context;
-            mActivity = activity;
-            mSources = Sources.getInstance(mContext);
-            mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
-        }
-
-        /**
-         * In group descriptions, show the number of contacts with phone
-         * numbers, in addition to the total contacts.
-         */
-        public void setChildDescripWithPhones(boolean withPhones) {
-            mChildWithPhones = withPhones;
-        }
-
-        @Override
-        protected View newGroupView(Context context, Cursor cursor, boolean isExpanded,
-                ViewGroup parent) {
-            return mInflater.inflate(R.layout.display_group, parent, false);
-        }
-
-        @Override
-        protected void bindGroupView(View view, Context context, Cursor cursor, boolean isExpanded) {
-            final TextView text1 = (TextView)view.findViewById(android.R.id.text1);
-            final TextView text2 = (TextView)view.findViewById(android.R.id.text2);
-
-            final String accountName = cursor.getString(SettingsQuery.ACCOUNT_NAME);
-            final String accountType = cursor.getString(SettingsQuery.ACCOUNT_TYPE);
-
-            final ContactsSource source = mSources.getInflatedSource(accountType,
-                    ContactsSource.LEVEL_SUMMARY);
-
-            text1.setText(source.getDisplayLabel(mContext));
-            text2.setText(accountName);
-            text2.setVisibility(accountName == null ? View.GONE : View.VISIBLE);
-        }
-
-        @Override
-        protected Cursor getChildrenCursor(Cursor groupCursor) {
-            final String selection = Groups.ACCOUNT_NAME + "=? AND " + Groups.ACCOUNT_TYPE
-                    + "=? AND " + Groups.SHOULD_SYNC + "=1";
-            final String[] selectionArgs = new String[] {
-                    groupCursor.getString(SettingsQuery.ACCOUNT_NAME),
-                    groupCursor.getString(SettingsQuery.ACCOUNT_TYPE)
-            };
-
-            final int position = groupCursor.getPosition();
-            final Cursor ungroupedCursor = new HeaderCursor(mContext, groupCursor, position);
-            final Cursor unsyncedCursor = new FooterCursor(mContext, groupCursor, position);
-
-            final ContentResolver resolver = mContext.getContentResolver();
-            final Cursor groupsCursor = resolver.query(Groups.CONTENT_SUMMARY_URI,
-                    GroupsQuery.PROJECTION, selection, selectionArgs, null);
-            mActivity.startManagingCursor(groupsCursor);
-
-            return new MergeCursor(new Cursor[] { ungroupedCursor, groupsCursor, unsyncedCursor });
-        }
-
-        @Override
-        protected View newChildView(Context context, Cursor cursor, boolean isLastChild,
-                ViewGroup parent) {
-            return mInflater.inflate(R.layout.display_child, parent, false);
-        }
-
-        @Override
-        protected void bindChildView(View view, Context context, Cursor cursor, boolean isLastChild) {
-            final TextView text1 = (TextView)view.findViewById(android.R.id.text1);
-            final TextView text2 = (TextView)view.findViewById(android.R.id.text2);
-            final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox);
-
-//            final int count = cursor.getInt(GroupsQuery.SUMMARY_COUNT);
-//            final int withPhones = cursor.getInt(GroupsQuery.SUMMARY_WITH_PHONES);
-            final int membersVisible = cursor.getInt(GroupsQuery.GROUP_VISIBLE);
-
-            // Read title, but override with string resource when present
-            final CharSequence title = getGroupTitle(mContext, cursor);
-//            final CharSequence descrip = mContext.getResources().getQuantityString(
-//                    mChildWithPhones ? R.plurals.groupDescripPhones : R.plurals.groupDescrip,
-//                    count, count, withPhones);
-
-            text1.setText(title);
-//            text2.setText(descrip);
-            checkbox.setChecked((membersVisible == 1));
-
-            // Hide extra views when recycled as footer
-            final boolean footerView = membersVisible == FOOTER_ENTRY;
-//            text2.setVisibility(footerView ? View.GONE : View.VISIBLE);
-            text2.setVisibility(View.GONE);
-            checkbox.setVisibility(footerView ? View.GONE : View.VISIBLE);
-        }
-    }
-
-    private interface SettingsQuery {
-        final String[] PROJECTION = new String[] {
-                Settings.ACCOUNT_NAME,
-                Settings.ACCOUNT_TYPE,
-                Settings.SHOULD_SYNC,
-                Settings.UNGROUPED_VISIBLE,
-                Settings.ANY_UNSYNCED,
-//                Settings.UNGROUPED_COUNT,
-//                Settings.UNGROUPED_WITH_PHONES,
-        };
-
-        final int ACCOUNT_NAME = 0;
-        final int ACCOUNT_TYPE = 1;
-        final int SHOULD_SYNC = 2;
-        final int UNGROUPED_VISIBLE = 3;
-        final int ANY_UNSYNCED = 4;
-//        final int UNGROUPED_COUNT = 5;
-//        final int UNGROUPED_WITH_PHONES = 6;
-    }
-
-    private interface GroupsQuery {
-        final String[] PROJECTION = new String[] {
-            Groups._ID,
-            Groups.TITLE,
-            Groups.RES_PACKAGE,
-            Groups.TITLE_RES,
-            Groups.GROUP_VISIBLE,
-            Groups.ACCOUNT_NAME,
-            Groups.ACCOUNT_TYPE,
-//            Groups.SUMMARY_COUNT,
-//            Groups.SUMMARY_WITH_PHONES,
-        };
-
-        final int _ID = 0;
-        final int TITLE = 1;
-        final int RES_PACKAGE = 2;
-        final int TITLE_RES = 3;
-        final int GROUP_VISIBLE = 4;
-        final int ACCOUNT_NAME = 5;
-        final int ACCOUNT_TYPE = 6;
-        final int SUMMARY_COUNT = 7;
-        final int SUMMARY_WITH_PHONES = 8;
-    }
 }