1/*
2 * Copyright (C) 2009 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 *      http://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17package com.android.contacts.common.list;
18
19import android.app.ActionBar;
20import android.app.Activity;
21import android.app.AlertDialog;
22import android.app.LoaderManager.LoaderCallbacks;
23import android.app.ProgressDialog;
24import android.content.AsyncTaskLoader;
25import android.content.ContentProviderOperation;
26import android.content.ContentResolver;
27import android.content.ContentValues;
28import android.content.Context;
29import android.content.DialogInterface;
30import android.content.Intent;
31import android.content.Loader;
32import android.content.OperationApplicationException;
33import android.content.SharedPreferences;
34import android.database.Cursor;
35import android.net.Uri;
36import android.os.Bundle;
37import android.os.RemoteException;
38import android.preference.PreferenceManager;
39import android.provider.ContactsContract;
40import android.provider.ContactsContract.Groups;
41import android.provider.ContactsContract.Settings;
42import android.util.Log;
43import android.view.ContextMenu;
44import android.view.LayoutInflater;
45import android.view.MenuItem;
46import android.view.MenuItem.OnMenuItemClickListener;
47import android.view.View;
48import android.view.ViewGroup;
49import android.widget.BaseExpandableListAdapter;
50import android.widget.CheckBox;
51import android.widget.ExpandableListAdapter;
52import android.widget.ExpandableListView;
53import android.widget.ExpandableListView.ExpandableListContextMenuInfo;
54import android.widget.TextView;
55
56import com.android.contacts.common.R;
57import com.android.contacts.common.model.AccountTypeManager;
58import com.android.contacts.common.model.ValuesDelta;
59import com.android.contacts.common.model.account.AccountType;
60import com.android.contacts.common.model.account.AccountWithDataSet;
61import com.android.contacts.common.model.account.GoogleAccountType;
62import com.android.contacts.common.util.EmptyService;
63import com.android.contacts.common.util.LocalizedNameResolver;
64import com.android.contacts.common.util.WeakAsyncTask;
65import com.google.common.collect.Lists;
66
67import java.util.ArrayList;
68import java.util.Collections;
69import java.util.Comparator;
70import java.util.Iterator;
71
72/**
73 * Shows a list of all available {@link Groups} available, letting the user
74 * select which ones they want to be visible.
75 */
76public class CustomContactListFilterActivity extends Activity
77        implements View.OnClickListener, ExpandableListView.OnChildClickListener,
78        LoaderCallbacks<CustomContactListFilterActivity.AccountSet>
79{
80    private static final String TAG = "CustomContactListFilterActivity";
81
82    private static final int ACCOUNT_SET_LOADER_ID = 1;
83
84    private ExpandableListView mList;
85    private DisplayAdapter mAdapter;
86
87    private SharedPreferences mPrefs;
88
89    @Override
90    protected void onCreate(Bundle icicle) {
91        super.onCreate(icicle);
92        setContentView(R.layout.contact_list_filter_custom);
93
94        mList = (ExpandableListView) findViewById(android.R.id.list);
95        mList.setOnChildClickListener(this);
96        mList.setHeaderDividersEnabled(true);
97        mPrefs = PreferenceManager.getDefaultSharedPreferences(this);
98        mAdapter = new DisplayAdapter(this);
99
100        final LayoutInflater inflater = getLayoutInflater();
101
102        findViewById(R.id.btn_done).setOnClickListener(this);
103        findViewById(R.id.btn_discard).setOnClickListener(this);
104
105        mList.setOnCreateContextMenuListener(this);
106
107        mList.setAdapter(mAdapter);
108
109        ActionBar actionBar = getActionBar();
110        if (actionBar != null) {
111            // android.R.id.home will be triggered in onOptionsItemSelected()
112            actionBar.setDisplayHomeAsUpEnabled(true);
113        }
114    }
115
116    public static class CustomFilterConfigurationLoader extends AsyncTaskLoader<AccountSet> {
117
118        private AccountSet mAccountSet;
119
120        public CustomFilterConfigurationLoader(Context context) {
121            super(context);
122        }
123
124        @Override
125        public AccountSet loadInBackground() {
126            Context context = getContext();
127            final AccountTypeManager accountTypes = AccountTypeManager.getInstance(context);
128            final ContentResolver resolver = context.getContentResolver();
129
130            final AccountSet accounts = new AccountSet();
131            for (AccountWithDataSet account : accountTypes.getAccounts(false)) {
132                final AccountType accountType = accountTypes.getAccountTypeForAccount(account);
133                if (accountType.isExtension() && !account.hasData(context)) {
134                    // Extension with no data -- skip.
135                    continue;
136                }
137
138                AccountDisplay accountDisplay =
139                        new AccountDisplay(resolver, account.name, account.type, account.dataSet);
140
141                final Uri.Builder groupsUri = Groups.CONTENT_URI.buildUpon()
142                        .appendQueryParameter(Groups.ACCOUNT_NAME, account.name)
143                        .appendQueryParameter(Groups.ACCOUNT_TYPE, account.type);
144                if (account.dataSet != null) {
145                    groupsUri.appendQueryParameter(Groups.DATA_SET, account.dataSet).build();
146                }
147                final Cursor cursor = resolver.query(groupsUri.build(), null, null, null, null);
148                if (cursor == null) {
149                    continue;
150                }
151                android.content.EntityIterator iterator =
152                        ContactsContract.Groups.newEntityIterator(cursor);
153                try {
154                    boolean hasGroups = false;
155
156                    // Create entries for each known group
157                    while (iterator.hasNext()) {
158                        final ContentValues values = iterator.next().getEntityValues();
159                        final GroupDelta group = GroupDelta.fromBefore(values);
160                        accountDisplay.addGroup(group);
161                        hasGroups = true;
162                    }
163                    // Create single entry handling ungrouped status
164                    accountDisplay.mUngrouped =
165                        GroupDelta.fromSettings(resolver, account.name, account.type,
166                                account.dataSet, hasGroups);
167                    accountDisplay.addGroup(accountDisplay.mUngrouped);
168                } finally {
169                    iterator.close();
170                }
171
172                accounts.add(accountDisplay);
173            }
174
175            return accounts;
176        }
177
178        @Override
179        public void deliverResult(AccountSet cursor) {
180            if (isReset()) {
181                return;
182            }
183
184            mAccountSet = cursor;
185
186            if (isStarted()) {
187                super.deliverResult(cursor);
188            }
189        }
190
191        @Override
192        protected void onStartLoading() {
193            if (mAccountSet != null) {
194                deliverResult(mAccountSet);
195            }
196            if (takeContentChanged() || mAccountSet == null) {
197                forceLoad();
198            }
199        }
200
201        @Override
202        protected void onStopLoading() {
203            cancelLoad();
204        }
205
206        @Override
207        protected void onReset() {
208            super.onReset();
209            onStopLoading();
210            mAccountSet = null;
211        }
212    }
213
214    @Override
215    protected void onStart() {
216        getLoaderManager().initLoader(ACCOUNT_SET_LOADER_ID, null, this);
217        super.onStart();
218    }
219
220    @Override
221    public Loader<AccountSet> onCreateLoader(int id, Bundle args) {
222        return new CustomFilterConfigurationLoader(this);
223    }
224
225    @Override
226    public void onLoadFinished(Loader<AccountSet> loader, AccountSet data) {
227        mAdapter.setAccounts(data);
228    }
229
230    @Override
231    public void onLoaderReset(Loader<AccountSet> loader) {
232        mAdapter.setAccounts(null);
233    }
234
235    private static final int DEFAULT_SHOULD_SYNC = 1;
236    private static final int DEFAULT_VISIBLE = 0;
237
238    /**
239     * Entry holding any changes to {@link Groups} or {@link Settings} rows,
240     * such as {@link Groups#SHOULD_SYNC} or {@link Groups#GROUP_VISIBLE}.
241     */
242    protected static class GroupDelta extends ValuesDelta {
243        private boolean mUngrouped = false;
244        private boolean mAccountHasGroups;
245
246        private GroupDelta() {
247            super();
248        }
249
250        /**
251         * Build {@link GroupDelta} from the {@link Settings} row for the given
252         * {@link Settings#ACCOUNT_NAME}, {@link Settings#ACCOUNT_TYPE}, and
253         * {@link Settings#DATA_SET}.
254         */
255        public static GroupDelta fromSettings(ContentResolver resolver, String accountName,
256                String accountType, String dataSet, boolean accountHasGroups) {
257            final Uri.Builder settingsUri = Settings.CONTENT_URI.buildUpon()
258                    .appendQueryParameter(Settings.ACCOUNT_NAME, accountName)
259                    .appendQueryParameter(Settings.ACCOUNT_TYPE, accountType);
260            if (dataSet != null) {
261                settingsUri.appendQueryParameter(Settings.DATA_SET, dataSet);
262            }
263            final Cursor cursor = resolver.query(settingsUri.build(), new String[] {
264                    Settings.SHOULD_SYNC, Settings.UNGROUPED_VISIBLE
265            }, null, null, null);
266
267            try {
268                final ContentValues values = new ContentValues();
269                values.put(Settings.ACCOUNT_NAME, accountName);
270                values.put(Settings.ACCOUNT_TYPE, accountType);
271                values.put(Settings.DATA_SET, dataSet);
272
273                if (cursor != null && cursor.moveToFirst()) {
274                    // Read existing values when present
275                    values.put(Settings.SHOULD_SYNC, cursor.getInt(0));
276                    values.put(Settings.UNGROUPED_VISIBLE, cursor.getInt(1));
277                    return fromBefore(values).setUngrouped(accountHasGroups);
278                } else {
279                    // Nothing found, so treat as create
280                    values.put(Settings.SHOULD_SYNC, DEFAULT_SHOULD_SYNC);
281                    values.put(Settings.UNGROUPED_VISIBLE, DEFAULT_VISIBLE);
282                    return fromAfter(values).setUngrouped(accountHasGroups);
283                }
284            } finally {
285                if (cursor != null) cursor.close();
286            }
287        }
288
289        public static GroupDelta fromBefore(ContentValues before) {
290            final GroupDelta entry = new GroupDelta();
291            entry.mBefore = before;
292            entry.mAfter = new ContentValues();
293            return entry;
294        }
295
296        public static GroupDelta fromAfter(ContentValues after) {
297            final GroupDelta entry = new GroupDelta();
298            entry.mBefore = null;
299            entry.mAfter = after;
300            return entry;
301        }
302
303        protected GroupDelta setUngrouped(boolean accountHasGroups) {
304            mUngrouped = true;
305            mAccountHasGroups = accountHasGroups;
306            return this;
307        }
308
309        @Override
310        public boolean beforeExists() {
311            return mBefore != null;
312        }
313
314        public boolean getShouldSync() {
315            return getAsInteger(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC,
316                    DEFAULT_SHOULD_SYNC) != 0;
317        }
318
319        public boolean getVisible() {
320            return getAsInteger(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE,
321                    DEFAULT_VISIBLE) != 0;
322        }
323
324        public void putShouldSync(boolean shouldSync) {
325            put(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC, shouldSync ? 1 : 0);
326        }
327
328        public void putVisible(boolean visible) {
329            put(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE, visible ? 1 : 0);
330        }
331
332        private String getAccountType() {
333            return (mBefore == null ? mAfter : mBefore).getAsString(Settings.ACCOUNT_TYPE);
334        }
335
336        public CharSequence getTitle(Context context) {
337            if (mUngrouped) {
338                final String customAllContactsName =
339                        LocalizedNameResolver.getAllContactsName(context, getAccountType());
340                if (customAllContactsName != null) {
341                    return customAllContactsName;
342                }
343                if (mAccountHasGroups) {
344                    return context.getText(R.string.display_ungrouped);
345                } else {
346                    return context.getText(R.string.display_all_contacts);
347                }
348            } else {
349                final Integer titleRes = getAsInteger(Groups.TITLE_RES);
350                if (titleRes != null) {
351                    final String packageName = getAsString(Groups.RES_PACKAGE);
352                    return context.getPackageManager().getText(packageName, titleRes, null);
353                } else {
354                    return getAsString(Groups.TITLE);
355                }
356            }
357        }
358
359        /**
360         * Build a possible {@link ContentProviderOperation} to persist any
361         * changes to the {@link Groups} or {@link Settings} row described by
362         * this {@link GroupDelta}.
363         */
364        public ContentProviderOperation buildDiff() {
365            if (isInsert()) {
366                // Only allow inserts for Settings
367                if (mUngrouped) {
368                    mAfter.remove(mIdColumn);
369                    return ContentProviderOperation.newInsert(Settings.CONTENT_URI)
370                            .withValues(mAfter)
371                            .build();
372                }
373                else {
374                    throw new IllegalStateException("Unexpected diff");
375                }
376            } else if (isUpdate()) {
377                if (mUngrouped) {
378                    String accountName = this.getAsString(Settings.ACCOUNT_NAME);
379                    String accountType = this.getAsString(Settings.ACCOUNT_TYPE);
380                    String dataSet = this.getAsString(Settings.DATA_SET);
381                    StringBuilder selection = new StringBuilder(Settings.ACCOUNT_NAME + "=? AND "
382                            + Settings.ACCOUNT_TYPE + "=?");
383                    String[] selectionArgs;
384                    if (dataSet == null) {
385                        selection.append(" AND " + Settings.DATA_SET + " IS NULL");
386                        selectionArgs = new String[] {accountName, accountType};
387                    } else {
388                        selection.append(" AND " + Settings.DATA_SET + "=?");
389                        selectionArgs = new String[] {accountName, accountType, dataSet};
390                    }
391                    return ContentProviderOperation.newUpdate(Settings.CONTENT_URI)
392                            .withSelection(selection.toString(), selectionArgs)
393                            .withValues(mAfter)
394                            .build();
395                } else {
396                    return ContentProviderOperation.newUpdate(
397                                    addCallerIsSyncAdapterParameter(Groups.CONTENT_URI))
398                            .withSelection(Groups._ID + "=" + this.getId(), null)
399                            .withValues(mAfter)
400                            .build();
401                }
402            } else {
403                return null;
404            }
405        }
406    }
407
408    private static Uri addCallerIsSyncAdapterParameter(Uri uri) {
409        return uri.buildUpon()
410            .appendQueryParameter(ContactsContract.CALLER_IS_SYNCADAPTER, "true")
411            .build();
412    }
413
414    /**
415     * {@link Comparator} to sort by {@link Groups#_ID}.
416     */
417    private static Comparator<GroupDelta> sIdComparator = new Comparator<GroupDelta>() {
418        public int compare(GroupDelta object1, GroupDelta object2) {
419            final Long id1 = object1.getId();
420            final Long id2 = object2.getId();
421            if (id1 == null && id2 == null) {
422                return 0;
423            } else if (id1 == null) {
424                return -1;
425            } else if (id2 == null) {
426                return 1;
427            } else if (id1 < id2) {
428                return -1;
429            } else if (id1 > id2) {
430                return 1;
431            } else {
432                return 0;
433            }
434        }
435    };
436
437    /**
438     * Set of all {@link AccountDisplay} entries, one for each source.
439     */
440    protected static class AccountSet extends ArrayList<AccountDisplay> {
441        public ArrayList<ContentProviderOperation> buildDiff() {
442            final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
443            for (AccountDisplay account : this) {
444                account.buildDiff(diff);
445            }
446            return diff;
447        }
448    }
449
450    /**
451     * {@link GroupDelta} details for a single {@link AccountWithDataSet}, usually shown as
452     * children under a single expandable group.
453     */
454    protected static class AccountDisplay {
455        public final String mName;
456        public final String mType;
457        public final String mDataSet;
458
459        public GroupDelta mUngrouped;
460        public ArrayList<GroupDelta> mSyncedGroups = Lists.newArrayList();
461        public ArrayList<GroupDelta> mUnsyncedGroups = Lists.newArrayList();
462
463        /**
464         * Build an {@link AccountDisplay} covering all {@link Groups} under the
465         * given {@link AccountWithDataSet}.
466         */
467        public AccountDisplay(ContentResolver resolver, String accountName, String accountType,
468                String dataSet) {
469            mName = accountName;
470            mType = accountType;
471            mDataSet = dataSet;
472        }
473
474        /**
475         * Add the given {@link GroupDelta} internally, filing based on its
476         * {@link GroupDelta#getShouldSync()} status.
477         */
478        private void addGroup(GroupDelta group) {
479            if (group.getShouldSync()) {
480                mSyncedGroups.add(group);
481            } else {
482                mUnsyncedGroups.add(group);
483            }
484        }
485
486        /**
487         * Set the {@link GroupDelta#putShouldSync(boolean)} value for all
488         * children {@link GroupDelta} rows.
489         */
490        public void setShouldSync(boolean shouldSync) {
491            final Iterator<GroupDelta> oppositeChildren = shouldSync ?
492                    mUnsyncedGroups.iterator() : mSyncedGroups.iterator();
493            while (oppositeChildren.hasNext()) {
494                final GroupDelta child = oppositeChildren.next();
495                setShouldSync(child, shouldSync, false);
496                oppositeChildren.remove();
497            }
498        }
499
500        public void setShouldSync(GroupDelta child, boolean shouldSync) {
501            setShouldSync(child, shouldSync, true);
502        }
503
504        /**
505         * Set {@link GroupDelta#putShouldSync(boolean)}, and file internally
506         * based on updated state.
507         */
508        public void setShouldSync(GroupDelta child, boolean shouldSync, boolean attemptRemove) {
509            child.putShouldSync(shouldSync);
510            if (shouldSync) {
511                if (attemptRemove) {
512                    mUnsyncedGroups.remove(child);
513                }
514                mSyncedGroups.add(child);
515                Collections.sort(mSyncedGroups, sIdComparator);
516            } else {
517                if (attemptRemove) {
518                    mSyncedGroups.remove(child);
519                }
520                mUnsyncedGroups.add(child);
521            }
522        }
523
524        /**
525         * Build set of {@link ContentProviderOperation} to persist any user
526         * changes to {@link GroupDelta} rows under this {@link AccountWithDataSet}.
527         */
528        public void buildDiff(ArrayList<ContentProviderOperation> diff) {
529            for (GroupDelta group : mSyncedGroups) {
530                final ContentProviderOperation oper = group.buildDiff();
531                if (oper != null) diff.add(oper);
532            }
533            for (GroupDelta group : mUnsyncedGroups) {
534                final ContentProviderOperation oper = group.buildDiff();
535                if (oper != null) diff.add(oper);
536            }
537        }
538    }
539
540    /**
541     * {@link ExpandableListAdapter} that shows {@link GroupDelta} settings,
542     * grouped by {@link AccountWithDataSet} type. Shows footer row when any groups are
543     * unsynced, as determined through {@link AccountDisplay#mUnsyncedGroups}.
544     */
545    protected static class DisplayAdapter extends BaseExpandableListAdapter {
546        private Context mContext;
547        private LayoutInflater mInflater;
548        private AccountTypeManager mAccountTypes;
549        private AccountSet mAccounts;
550
551        private boolean mChildWithPhones = false;
552
553        public DisplayAdapter(Context context) {
554            mContext = context;
555            mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
556            mAccountTypes = AccountTypeManager.getInstance(context);
557        }
558
559        public void setAccounts(AccountSet accounts) {
560            mAccounts = accounts;
561            notifyDataSetChanged();
562        }
563
564        /**
565         * In group descriptions, show the number of contacts with phone
566         * numbers, in addition to the total contacts.
567         */
568        public void setChildDescripWithPhones(boolean withPhones) {
569            mChildWithPhones = withPhones;
570        }
571
572        @Override
573        public View getGroupView(int groupPosition, boolean isExpanded, View convertView,
574                ViewGroup parent) {
575            if (convertView == null) {
576                convertView = mInflater.inflate(
577                        R.layout.custom_contact_list_filter_account, parent, false);
578            }
579
580            final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
581            final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
582
583            final AccountDisplay account = (AccountDisplay)this.getGroup(groupPosition);
584
585            final AccountType accountType = mAccountTypes.getAccountType(
586                    account.mType, account.mDataSet);
587
588            text1.setText(account.mName);
589            text1.setVisibility(account.mName == null ? View.GONE : View.VISIBLE);
590            text2.setText(accountType.getDisplayLabel(mContext));
591
592            return convertView;
593        }
594
595        @Override
596        public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
597                View convertView, ViewGroup parent) {
598            if (convertView == null) {
599                convertView = mInflater.inflate(
600                        R.layout.custom_contact_list_filter_group, parent, false);
601            }
602
603            final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
604            final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
605            final CheckBox checkbox = (CheckBox)convertView.findViewById(android.R.id.checkbox);
606
607            final AccountDisplay account = mAccounts.get(groupPosition);
608            final GroupDelta child = (GroupDelta)this.getChild(groupPosition, childPosition);
609            if (child != null) {
610                // Handle normal group, with title and checkbox
611                final boolean groupVisible = child.getVisible();
612                checkbox.setVisibility(View.VISIBLE);
613                checkbox.setChecked(groupVisible);
614
615                final CharSequence groupTitle = child.getTitle(mContext);
616                text1.setText(groupTitle);
617                text2.setVisibility(View.GONE);
618            } else {
619                // When unknown child, this is "more" footer view
620                checkbox.setVisibility(View.GONE);
621                text1.setText(R.string.display_more_groups);
622                text2.setVisibility(View.GONE);
623            }
624
625            return convertView;
626        }
627
628        @Override
629        public Object getChild(int groupPosition, int childPosition) {
630            final AccountDisplay account = mAccounts.get(groupPosition);
631            final boolean validChild = childPosition >= 0
632                    && childPosition < account.mSyncedGroups.size();
633            if (validChild) {
634                return account.mSyncedGroups.get(childPosition);
635            } else {
636                return null;
637            }
638        }
639
640        @Override
641        public long getChildId(int groupPosition, int childPosition) {
642            final GroupDelta child = (GroupDelta)getChild(groupPosition, childPosition);
643            if (child != null) {
644                final Long childId = child.getId();
645                return childId != null ? childId : Long.MIN_VALUE;
646            } else {
647                return Long.MIN_VALUE;
648            }
649        }
650
651        @Override
652        public int getChildrenCount(int groupPosition) {
653            // Count is any synced groups, plus possible footer
654            final AccountDisplay account = mAccounts.get(groupPosition);
655            final boolean anyHidden = account.mUnsyncedGroups.size() > 0;
656            return account.mSyncedGroups.size() + (anyHidden ? 1 : 0);
657        }
658
659        @Override
660        public Object getGroup(int groupPosition) {
661            return mAccounts.get(groupPosition);
662        }
663
664        @Override
665        public int getGroupCount() {
666            if (mAccounts == null) {
667                return 0;
668            }
669            return mAccounts.size();
670        }
671
672        @Override
673        public long getGroupId(int groupPosition) {
674            return groupPosition;
675        }
676
677        @Override
678        public boolean hasStableIds() {
679            return true;
680        }
681
682        @Override
683        public boolean isChildSelectable(int groupPosition, int childPosition) {
684            return true;
685        }
686    }
687
688    /** {@inheritDoc} */
689    public void onClick(View view) {
690        switch (view.getId()) {
691            case R.id.btn_done: {
692                this.doSaveAction();
693                break;
694            }
695            case R.id.btn_discard: {
696                this.finish();
697                break;
698            }
699        }
700    }
701
702    /**
703     * Handle any clicks on {@link ExpandableListAdapter} children, which
704     * usually mean toggling its visible state.
705     */
706    @Override
707    public boolean onChildClick(ExpandableListView parent, View view, int groupPosition,
708            int childPosition, long id) {
709        final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox);
710
711        final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
712        final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
713        if (child != null) {
714            checkbox.toggle();
715            child.putVisible(checkbox.isChecked());
716        } else {
717            // Open context menu for bringing back unsynced
718            this.openContextMenu(view);
719        }
720        return true;
721    }
722
723    // TODO: move these definitions to framework constants when we begin
724    // defining this mode through <sync-adapter> tags
725    private static final int SYNC_MODE_UNSUPPORTED = 0;
726    private static final int SYNC_MODE_UNGROUPED = 1;
727    private static final int SYNC_MODE_EVERYTHING = 2;
728
729    protected int getSyncMode(AccountDisplay account) {
730        // TODO: read sync mode through <sync-adapter> definition
731        if (GoogleAccountType.ACCOUNT_TYPE.equals(account.mType) && account.mDataSet == null) {
732            return SYNC_MODE_EVERYTHING;
733        } else {
734            return SYNC_MODE_UNSUPPORTED;
735        }
736    }
737
738    @Override
739    public void onCreateContextMenu(ContextMenu menu, View view,
740            ContextMenu.ContextMenuInfo menuInfo) {
741        super.onCreateContextMenu(menu, view, menuInfo);
742
743        // Bail if not working with expandable long-press, or if not child
744        if (!(menuInfo instanceof ExpandableListContextMenuInfo)) return;
745
746        final ExpandableListContextMenuInfo info = (ExpandableListContextMenuInfo) menuInfo;
747        final int groupPosition = ExpandableListView.getPackedPositionGroup(info.packedPosition);
748        final int childPosition = ExpandableListView.getPackedPositionChild(info.packedPosition);
749
750        // Skip long-press on expandable parents
751        if (childPosition == -1) return;
752
753        final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
754        final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
755
756        // Ignore when selective syncing unsupported
757        final int syncMode = getSyncMode(account);
758        if (syncMode == SYNC_MODE_UNSUPPORTED) return;
759
760        if (child != null) {
761            showRemoveSync(menu, account, child, syncMode);
762        } else {
763            showAddSync(menu, account, syncMode);
764        }
765    }
766
767    protected void showRemoveSync(ContextMenu menu, final AccountDisplay account,
768            final GroupDelta child, final int syncMode) {
769        final CharSequence title = child.getTitle(this);
770
771        menu.setHeaderTitle(title);
772        menu.add(R.string.menu_sync_remove).setOnMenuItemClickListener(
773                new OnMenuItemClickListener() {
774                    public boolean onMenuItemClick(MenuItem item) {
775                        handleRemoveSync(account, child, syncMode, title);
776                        return true;
777                    }
778                });
779    }
780
781    protected void handleRemoveSync(final AccountDisplay account, final GroupDelta child,
782            final int syncMode, CharSequence title) {
783        final boolean shouldSyncUngrouped = account.mUngrouped.getShouldSync();
784        if (syncMode == SYNC_MODE_EVERYTHING && shouldSyncUngrouped
785                && !child.equals(account.mUngrouped)) {
786            // Warn before removing this group when it would cause ungrouped to stop syncing
787            final AlertDialog.Builder builder = new AlertDialog.Builder(this);
788            final CharSequence removeMessage = this.getString(
789                    R.string.display_warn_remove_ungrouped, title);
790            builder.setTitle(R.string.menu_sync_remove);
791            builder.setMessage(removeMessage);
792            builder.setNegativeButton(android.R.string.cancel, null);
793            builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
794                public void onClick(DialogInterface dialog, int which) {
795                    // Mark both this group and ungrouped to stop syncing
796                    account.setShouldSync(account.mUngrouped, false);
797                    account.setShouldSync(child, false);
798                    mAdapter.notifyDataSetChanged();
799                }
800            });
801            builder.show();
802        } else {
803            // Mark this group to not sync
804            account.setShouldSync(child, false);
805            mAdapter.notifyDataSetChanged();
806        }
807    }
808
809    protected void showAddSync(ContextMenu menu, final AccountDisplay account, final int syncMode) {
810        menu.setHeaderTitle(R.string.dialog_sync_add);
811
812        // Create item for each available, unsynced group
813        for (final GroupDelta child : account.mUnsyncedGroups) {
814            if (!child.getShouldSync()) {
815                final CharSequence title = child.getTitle(this);
816                menu.add(title).setOnMenuItemClickListener(new OnMenuItemClickListener() {
817                    public boolean onMenuItemClick(MenuItem item) {
818                        // Adding specific group for syncing
819                        if (child.mUngrouped && syncMode == SYNC_MODE_EVERYTHING) {
820                            account.setShouldSync(true);
821                        } else {
822                            account.setShouldSync(child, true);
823                        }
824                        mAdapter.notifyDataSetChanged();
825                        return true;
826                    }
827                });
828            }
829        }
830    }
831
832    @SuppressWarnings("unchecked")
833    private void doSaveAction() {
834        if (mAdapter == null || mAdapter.mAccounts == null) {
835            finish();
836            return;
837        }
838
839        setResult(RESULT_OK);
840
841        final ArrayList<ContentProviderOperation> diff = mAdapter.mAccounts.buildDiff();
842        if (diff.isEmpty()) {
843            finish();
844            return;
845        }
846
847        new UpdateTask(this).execute(diff);
848    }
849
850    /**
851     * Background task that persists changes to {@link Groups#GROUP_VISIBLE},
852     * showing spinner dialog to user while updating.
853     */
854    public static class UpdateTask extends
855            WeakAsyncTask<ArrayList<ContentProviderOperation>, Void, Void, Activity> {
856        private ProgressDialog mProgress;
857
858        public UpdateTask(Activity target) {
859            super(target);
860        }
861
862        /** {@inheritDoc} */
863        @Override
864        protected void onPreExecute(Activity target) {
865            final Context context = target;
866
867            mProgress = ProgressDialog.show(
868                    context, null, context.getText(R.string.savingDisplayGroups));
869
870            // Before starting this task, start an empty service to protect our
871            // process from being reclaimed by the system.
872            context.startService(new Intent(context, EmptyService.class));
873        }
874
875        /** {@inheritDoc} */
876        @Override
877        protected Void doInBackground(
878                Activity target, ArrayList<ContentProviderOperation>... params) {
879            final Context context = target;
880            final ContentValues values = new ContentValues();
881            final ContentResolver resolver = context.getContentResolver();
882
883            try {
884                final ArrayList<ContentProviderOperation> diff = params[0];
885                resolver.applyBatch(ContactsContract.AUTHORITY, diff);
886            } catch (RemoteException e) {
887                Log.e(TAG, "Problem saving display groups", e);
888            } catch (OperationApplicationException e) {
889                Log.e(TAG, "Problem saving display groups", e);
890            }
891
892            return null;
893        }
894
895        /** {@inheritDoc} */
896        @Override
897        protected void onPostExecute(Activity target, Void result) {
898            final Context context = target;
899
900            try {
901                mProgress.dismiss();
902            } catch (Exception e) {
903                Log.e(TAG, "Error dismissing progress dialog", e);
904            }
905
906            target.finish();
907
908            // Stop the service that was protecting us
909            context.stopService(new Intent(context, EmptyService.class));
910        }
911    }
912
913    @Override
914    public boolean onOptionsItemSelected(MenuItem item) {
915        switch (item.getItemId()) {
916            case android.R.id.home:
917                // Pretend cancel.
918                setResult(Activity.RESULT_CANCELED);
919                finish();
920                return true;
921            default:
922                break;
923        }
924        return super.onOptionsItemSelected(item);
925    }
926}
927