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 
17 package com.android.contacts.list;
18 
19 import android.app.ActionBar;
20 import android.app.Activity;
21 import android.app.AlertDialog;
22 import android.app.Dialog;
23 import android.app.DialogFragment;
24 import android.app.LoaderManager.LoaderCallbacks;
25 import android.app.ProgressDialog;
26 import android.content.ContentProviderOperation;
27 import android.content.ContentResolver;
28 import android.content.ContentValues;
29 import android.content.Context;
30 import android.content.DialogInterface;
31 import android.content.Intent;
32 import android.content.IntentFilter;
33 import android.content.Loader;
34 import android.content.OperationApplicationException;
35 import android.database.Cursor;
36 import android.graphics.Color;
37 import android.graphics.drawable.ColorDrawable;
38 import android.net.Uri;
39 import android.os.Bundle;
40 import android.os.RemoteException;
41 import android.provider.ContactsContract;
42 import android.provider.ContactsContract.Groups;
43 import android.provider.ContactsContract.Settings;
44 import android.util.Log;
45 import android.view.ContextMenu;
46 import android.view.LayoutInflater;
47 import android.view.Menu;
48 import android.view.MenuItem;
49 import android.view.MenuItem.OnMenuItemClickListener;
50 import android.view.View;
51 import android.view.ViewGroup;
52 import android.widget.BaseExpandableListAdapter;
53 import android.widget.CheckBox;
54 import android.widget.ExpandableListAdapter;
55 import android.widget.ExpandableListView;
56 import android.widget.ExpandableListView.ExpandableListContextMenuInfo;
57 import android.widget.TextView;
58 
59 import com.android.contacts.R;
60 import com.android.contacts.model.AccountTypeManager;
61 import com.android.contacts.model.ValuesDelta;
62 import com.android.contacts.model.account.AccountInfo;
63 import com.android.contacts.model.account.AccountWithDataSet;
64 import com.android.contacts.model.account.GoogleAccountType;
65 import com.android.contacts.util.EmptyService;
66 import com.android.contacts.util.LocalizedNameResolver;
67 import com.android.contacts.util.WeakAsyncTask;
68 import com.android.contacts.util.concurrent.ContactsExecutors;
69 import com.android.contacts.util.concurrent.ListenableFutureLoader;
70 import com.google.common.base.Function;
71 import com.google.common.collect.Lists;
72 import com.google.common.util.concurrent.Futures;
73 import com.google.common.util.concurrent.ListenableFuture;
74 
75 import java.util.ArrayList;
76 import java.util.Collections;
77 import java.util.Comparator;
78 import java.util.Iterator;
79 import java.util.List;
80 
81 import javax.annotation.Nullable;
82 
83 /**
84  * Shows a list of all available {@link Groups} available, letting the user
85  * select which ones they want to be visible.
86  */
87 public class CustomContactListFilterActivity extends Activity implements
88         ExpandableListView.OnChildClickListener,
89         LoaderCallbacks<CustomContactListFilterActivity.AccountSet> {
90     private static final String TAG = "CustomContactListFilter";
91 
92     public static final String EXTRA_CURRENT_LIST_FILTER_TYPE = "currentListFilterType";
93 
94     private static final int ACCOUNT_SET_LOADER_ID = 1;
95 
96     private ExpandableListView mList;
97     private DisplayAdapter mAdapter;
98 
99     @Override
onCreate(Bundle icicle)100     protected void onCreate(Bundle icicle) {
101         super.onCreate(icicle);
102         setContentView(R.layout.contact_list_filter_custom);
103 
104         mList = (ExpandableListView) findViewById(android.R.id.list);
105         mList.setOnChildClickListener(this);
106         mList.setHeaderDividersEnabled(true);
107         mList.setChildDivider(new ColorDrawable(Color.TRANSPARENT));
108 
109         mList.addOnLayoutChangeListener(new View.OnLayoutChangeListener() {
110             @Override
111             public void onLayoutChange(final View v, final int left, final int top, final int right,
112                     final int bottom, final int oldLeft, final int oldTop, final int oldRight,
113                     final int oldBottom) {
114                 mList.setIndicatorBounds(
115                         mList.getWidth() - getResources().getDimensionPixelSize(
116                                 R.dimen.contact_filter_indicator_padding_end),
117                         mList.getWidth() - getResources().getDimensionPixelSize(
118                                 R.dimen.contact_filter_indicator_padding_start));
119             }
120         });
121 
122         mAdapter = new DisplayAdapter(this);
123 
124         mList.setOnCreateContextMenuListener(this);
125 
126         mList.setAdapter(mAdapter);
127 
128         ActionBar actionBar = getActionBar();
129         if (actionBar != null) {
130             // android.R.id.home will be triggered in onOptionsItemSelected()
131             actionBar.setDisplayHomeAsUpEnabled(true);
132         }
133     }
134 
135     public static class CustomFilterConfigurationLoader extends ListenableFutureLoader<AccountSet> {
136 
137         private AccountTypeManager mAccountTypeManager;
138 
CustomFilterConfigurationLoader(Context context)139         public CustomFilterConfigurationLoader(Context context) {
140             super(context, new IntentFilter(AccountTypeManager.BROADCAST_ACCOUNTS_CHANGED));
141             mAccountTypeManager = AccountTypeManager.getInstance(context);
142         }
143 
144         @Override
loadData()145         public ListenableFuture<AccountSet> loadData() {
146             return Futures.transform(mAccountTypeManager.getAccountsAsync(),
147                     new Function<List<AccountInfo>, AccountSet>() {
148                 @Nullable
149                 @Override
150                 public AccountSet apply(@Nullable List<AccountInfo> input) {
151                     return createAccountSet(input);
152                 }
153             }, ContactsExecutors.getDefaultThreadPoolExecutor());
154         }
155 
createAccountSet(List<AccountInfo> sourceAccounts)156         private AccountSet createAccountSet(List<AccountInfo> sourceAccounts) {
157             final Context context = getContext();
158             final ContentResolver resolver = context.getContentResolver();
159 
160             final AccountSet accounts = new AccountSet();
161 
162             // Don't include the null account because it doesn't support writing to
163             // ContactsContract.Settings
164             for (AccountInfo info : sourceAccounts) {
165                 final AccountWithDataSet account = info.getAccount();
166                 // Don't include the null account because it doesn't support writing to
167                 // ContactsContract.Settings
168                 if (account.isNullAccount()) {
169                     continue;
170                 }
171 
172                 final AccountDisplay accountDisplay = new AccountDisplay(resolver, info);
173 
174                 final Uri.Builder groupsUri = Groups.CONTENT_URI.buildUpon()
175                         .appendQueryParameter(Groups.ACCOUNT_NAME, account.name)
176                         .appendQueryParameter(Groups.ACCOUNT_TYPE, account.type);
177                 if (account.dataSet != null) {
178                     groupsUri.appendQueryParameter(Groups.DATA_SET, account.dataSet).build();
179                 }
180                 final Cursor cursor = resolver.query(groupsUri.build(), null,
181                         Groups.DELETED + "=0", null, null);
182                 if (cursor == null) {
183                     continue;
184                 }
185                 android.content.EntityIterator iterator =
186                         ContactsContract.Groups.newEntityIterator(cursor);
187                 try {
188                     boolean hasGroups = false;
189 
190                     // Create entries for each known group
191                     while (iterator.hasNext()) {
192                         final ContentValues values = iterator.next().getEntityValues();
193                         final GroupDelta group = GroupDelta.fromBefore(values);
194                         accountDisplay.addGroup(group);
195                         hasGroups = true;
196                     }
197                     // Create single entry handling ungrouped status
198                     accountDisplay.mUngrouped =
199                         GroupDelta.fromSettings(resolver, account.name, account.type,
200                                 account.dataSet, hasGroups);
201                     accountDisplay.addGroup(accountDisplay.mUngrouped);
202                 } finally {
203                     iterator.close();
204                 }
205 
206                 accounts.add(accountDisplay);
207             }
208 
209             return accounts;
210         }
211     }
212 
213     @Override
214     protected void onStart() {
215         getLoaderManager().initLoader(ACCOUNT_SET_LOADER_ID, null, this);
216         super.onStart();
217     }
218 
219     @Override
220     public Loader<AccountSet> onCreateLoader(int id, Bundle args) {
221         return new CustomFilterConfigurationLoader(this);
222     }
223 
224     @Override
225     public void onLoadFinished(Loader<AccountSet> loader, AccountSet data) {
226         mAdapter.setAccounts(data);
227     }
228 
229     @Override
230     public void onLoaderReset(Loader<AccountSet> loader) {
231         mAdapter.setAccounts(null);
232     }
233 
234     private static final int DEFAULT_SHOULD_SYNC = 1;
235     private static final int DEFAULT_VISIBLE = 0;
236 
237     /**
238      * Entry holding any changes to {@link Groups} or {@link Settings} rows,
239      * such as {@link Groups#SHOULD_SYNC} or {@link Groups#GROUP_VISIBLE}.
240      */
241     protected static class GroupDelta extends ValuesDelta {
242         private boolean mUngrouped = false;
243         private boolean mAccountHasGroups;
244 
245         private GroupDelta() {
246             super();
247         }
248 
249         /**
250          * Build {@link GroupDelta} from the {@link Settings} row for the given
251          * {@link Settings#ACCOUNT_NAME}, {@link Settings#ACCOUNT_TYPE}, and
252          * {@link Settings#DATA_SET}.
253          */
254         public static GroupDelta fromSettings(ContentResolver resolver, String accountName,
255                 String accountType, String dataSet, boolean accountHasGroups) {
256             final Uri.Builder settingsUri = Settings.CONTENT_URI.buildUpon()
257                     .appendQueryParameter(Settings.ACCOUNT_NAME, accountName)
258                     .appendQueryParameter(Settings.ACCOUNT_TYPE, accountType);
259             if (dataSet != null) {
260                 settingsUri.appendQueryParameter(Settings.DATA_SET, dataSet);
261             }
262             final Cursor cursor = resolver.query(settingsUri.build(), new String[] {
263                     Settings.SHOULD_SYNC, Settings.UNGROUPED_VISIBLE
264             }, null, null, null);
265 
266             try {
267                 final ContentValues values = new ContentValues();
268                 values.put(Settings.ACCOUNT_NAME, accountName);
269                 values.put(Settings.ACCOUNT_TYPE, accountType);
270                 values.put(Settings.DATA_SET, dataSet);
271 
272                 if (cursor != null && cursor.moveToFirst()) {
273                     // Read existing values when present
274                     values.put(Settings.SHOULD_SYNC, cursor.getInt(0));
275                     values.put(Settings.UNGROUPED_VISIBLE, cursor.getInt(1));
276                     return fromBefore(values).setUngrouped(accountHasGroups);
277                 } else {
278                     // Nothing found, so treat as create
279                     values.put(Settings.SHOULD_SYNC, DEFAULT_SHOULD_SYNC);
280                     values.put(Settings.UNGROUPED_VISIBLE, DEFAULT_VISIBLE);
281                     return fromAfter(values).setUngrouped(accountHasGroups);
282                 }
283             } finally {
284                 if (cursor != null) cursor.close();
285             }
286         }
287 
288         public static GroupDelta fromBefore(ContentValues before) {
289             final GroupDelta entry = new GroupDelta();
290             entry.mBefore = before;
291             entry.mAfter = new ContentValues();
292             return entry;
293         }
294 
295         public static GroupDelta fromAfter(ContentValues after) {
296             final GroupDelta entry = new GroupDelta();
297             entry.mBefore = null;
298             entry.mAfter = after;
299             return entry;
300         }
301 
302         protected GroupDelta setUngrouped(boolean accountHasGroups) {
303             mUngrouped = true;
304             mAccountHasGroups = accountHasGroups;
305             return this;
306         }
307 
308         @Override
309         public boolean beforeExists() {
310             return mBefore != null;
311         }
312 
313         public boolean getShouldSync() {
314             return getAsInteger(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC,
315                     DEFAULT_SHOULD_SYNC) != 0;
316         }
317 
318         public boolean getVisible() {
319             return getAsInteger(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE,
320                     DEFAULT_VISIBLE) != 0;
321         }
322 
323         public void putShouldSync(boolean shouldSync) {
324             put(mUngrouped ? Settings.SHOULD_SYNC : Groups.SHOULD_SYNC, shouldSync ? 1 : 0);
325         }
326 
327         public void putVisible(boolean visible) {
328             put(mUngrouped ? Settings.UNGROUPED_VISIBLE : Groups.GROUP_VISIBLE, visible ? 1 : 0);
329         }
330 
331         private String getAccountType() {
332             return (mBefore == null ? mAfter : mBefore).getAsString(Settings.ACCOUNT_TYPE);
333         }
334 
335         public CharSequence getTitle(Context context) {
336             if (mUngrouped) {
337                 final String customAllContactsName =
338                         LocalizedNameResolver.getAllContactsName(context, getAccountType());
339                 if (customAllContactsName != null) {
340                     return customAllContactsName;
341                 }
342                 if (mAccountHasGroups) {
343                     return context.getText(R.string.display_ungrouped);
344                 } else {
345                     return context.getText(R.string.display_all_contacts);
346                 }
347             } else {
348                 final Integer titleRes = getAsInteger(Groups.TITLE_RES);
349                 if (titleRes != null && titleRes != 0) {
350                     final String packageName = getAsString(Groups.RES_PACKAGE);
351                     if (packageName != null) {
352                         return context.getPackageManager().getText(packageName, titleRes, null);
353                     }
354                 }
355                 return getAsString(Groups.TITLE);
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         public final AccountInfo mAccountInfo;
459 
460         public GroupDelta mUngrouped;
461         public ArrayList<GroupDelta> mSyncedGroups = Lists.newArrayList();
462         public ArrayList<GroupDelta> mUnsyncedGroups = Lists.newArrayList();
463 
464         public GroupDelta getGroup(int position) {
465             if (position < mSyncedGroups.size()) {
466                 return mSyncedGroups.get(position);
467             }
468             position -= mSyncedGroups.size();
469             return mUnsyncedGroups.get(position);
470         }
471 
472         /**
473          * Build an {@link AccountDisplay} covering all {@link Groups} under the
474          * given {@link AccountWithDataSet}.
475          */
476         public AccountDisplay(ContentResolver resolver, AccountInfo accountInfo) {
477             mName = accountInfo.getAccount().name;
478             mType = accountInfo.getAccount().type;
479             mDataSet = accountInfo.getAccount().dataSet;
480             mAccountInfo = accountInfo;
481         }
482 
483         /**
484          * Add the given {@link GroupDelta} internally, filing based on its
485          * {@link GroupDelta#getShouldSync()} status.
486          */
487         private void addGroup(GroupDelta group) {
488             if (group.getShouldSync()) {
489                 mSyncedGroups.add(group);
490             } else {
491                 mUnsyncedGroups.add(group);
492             }
493         }
494 
495         /**
496          * Set the {@link GroupDelta#putShouldSync(boolean)} value for all
497          * children {@link GroupDelta} rows.
498          */
499         public void setShouldSync(boolean shouldSync) {
500             final Iterator<GroupDelta> oppositeChildren = shouldSync ?
501                     mUnsyncedGroups.iterator() : mSyncedGroups.iterator();
502             while (oppositeChildren.hasNext()) {
503                 final GroupDelta child = oppositeChildren.next();
504                 setShouldSync(child, shouldSync, false);
505                 oppositeChildren.remove();
506             }
507         }
508 
509         public void setShouldSync(GroupDelta child, boolean shouldSync) {
510             setShouldSync(child, shouldSync, true);
511         }
512 
513         /**
514          * Set {@link GroupDelta#putShouldSync(boolean)}, and file internally
515          * based on updated state.
516          */
517         public void setShouldSync(GroupDelta child, boolean shouldSync, boolean attemptRemove) {
518             child.putShouldSync(shouldSync);
519             if (shouldSync) {
520                 if (attemptRemove) {
521                     mUnsyncedGroups.remove(child);
522                 }
523                 mSyncedGroups.add(child);
524                 Collections.sort(mSyncedGroups, sIdComparator);
525             } else {
526                 if (attemptRemove) {
527                     mSyncedGroups.remove(child);
528                 }
529                 mUnsyncedGroups.add(child);
530             }
531         }
532 
533         /**
534          * Build set of {@link ContentProviderOperation} to persist any user
535          * changes to {@link GroupDelta} rows under this {@link AccountWithDataSet}.
536          */
537         public void buildDiff(ArrayList<ContentProviderOperation> diff) {
538             for (GroupDelta group : mSyncedGroups) {
539                 final ContentProviderOperation oper = group.buildDiff();
540                 if (oper != null) diff.add(oper);
541             }
542             for (GroupDelta group : mUnsyncedGroups) {
543                 final ContentProviderOperation oper = group.buildDiff();
544                 if (oper != null) diff.add(oper);
545             }
546         }
547     }
548 
549     /**
550      * {@link ExpandableListAdapter} that shows {@link GroupDelta} settings,
551      * grouped by {@link AccountWithDataSet} type. Shows footer row when any groups are
552      * unsynced, as determined through {@link AccountDisplay#mUnsyncedGroups}.
553      */
554     protected static class DisplayAdapter extends BaseExpandableListAdapter {
555         private Context mContext;
556         private LayoutInflater mInflater;
557         private AccountTypeManager mAccountTypes;
558         private AccountSet mAccounts;
559 
560         private boolean mChildWithPhones = false;
561 
562         public DisplayAdapter(Context context) {
563             mContext = context;
564             mInflater = (LayoutInflater)context.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
565             mAccountTypes = AccountTypeManager.getInstance(context);
566         }
567 
568         public void setAccounts(AccountSet accounts) {
569             mAccounts = accounts;
570             notifyDataSetChanged();
571         }
572 
573         /**
574          * In group descriptions, show the number of contacts with phone
575          * numbers, in addition to the total contacts.
576          */
577         public void setChildDescripWithPhones(boolean withPhones) {
578             mChildWithPhones = withPhones;
579         }
580 
581         @Override
582         public View getGroupView(int groupPosition, boolean isExpanded, View convertView,
583                 ViewGroup parent) {
584             if (convertView == null) {
585                 convertView = mInflater.inflate(
586                         R.layout.custom_contact_list_filter_account, parent, false);
587             }
588 
589             final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
590             final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
591 
592             final AccountDisplay account = (AccountDisplay)this.getGroup(groupPosition);
593 
594             text1.setText(account.mAccountInfo.getNameLabel());
595             text1.setVisibility(!account.mAccountInfo.isDeviceAccount()
596                     || account.mAccountInfo.hasDistinctName()
597                     ? View.VISIBLE : View.GONE);
598             text2.setText(account.mAccountInfo.getTypeLabel());
599 
600             final int textColor = mContext.getResources().getColor(isExpanded
601                     ? R.color.dialtacts_theme_color
602                     : R.color.account_filter_text_color);
603             text1.setTextColor(textColor);
604             text2.setTextColor(textColor);
605 
606             return convertView;
607         }
608 
609         @Override
610         public View getChildView(int groupPosition, int childPosition, boolean isLastChild,
611                 View convertView, ViewGroup parent) {
612             if (convertView == null) {
613                 convertView = mInflater.inflate(
614                         R.layout.custom_contact_list_filter_group, parent, false);
615             }
616 
617             final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
618             final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
619             final CheckBox checkbox = (CheckBox)convertView.findViewById(android.R.id.checkbox);
620 
621             final AccountDisplay account = mAccounts.get(groupPosition);
622             final GroupDelta child = (GroupDelta)this.getChild(groupPosition, childPosition);
623             if (child != null) {
624                 // Handle normal group, with title and checkbox
625                 final boolean groupVisible = child.getVisible();
626                 checkbox.setVisibility(View.VISIBLE);
627                 checkbox.setChecked(groupVisible);
628 
629                 final CharSequence groupTitle = child.getTitle(mContext);
630                 text1.setText(groupTitle);
631                 text2.setVisibility(View.GONE);
632             } else {
633                 // When unknown child, this is "more" footer view
634                 checkbox.setVisibility(View.GONE);
635                 text1.setText(R.string.display_more_groups);
636                 text2.setVisibility(View.GONE);
637             }
638 
639             // Show divider at bottom only for the last child.
640             final View dividerBottom = convertView.findViewById(R.id.adapter_divider_bottom);
641             dividerBottom.setVisibility(isLastChild ? View.VISIBLE : View.GONE);
642 
643             return convertView;
644         }
645 
646         @Override
647         public Object getChild(int groupPosition, int childPosition) {
648             final AccountDisplay account = mAccounts.get(groupPosition);
649             final boolean validChild = childPosition >= 0
650                     && childPosition < account.mSyncedGroups.size()
651                     + account.mUnsyncedGroups.size();
652             if (validChild) {
653                 return account.getGroup(childPosition);
654             } else {
655                 return null;
656             }
657         }
658 
659         @Override
660         public long getChildId(int groupPosition, int childPosition) {
661             final GroupDelta child = (GroupDelta)getChild(groupPosition, childPosition);
662             if (child != null) {
663                 final Long childId = child.getId();
664                 return childId != null ? childId : Long.MIN_VALUE;
665             } else {
666                 return Long.MIN_VALUE;
667             }
668         }
669 
670         @Override
671         public int getChildrenCount(int groupPosition) {
672             // Count is any synced groups, plus possible footer
673             final AccountDisplay account = mAccounts.get(groupPosition);
674             return account.mSyncedGroups.size() + account.mUnsyncedGroups.size();
675         }
676 
677         @Override
678         public Object getGroup(int groupPosition) {
679             return mAccounts.get(groupPosition);
680         }
681 
682         @Override
683         public int getGroupCount() {
684             if (mAccounts == null) {
685                 return 0;
686             }
687             return mAccounts.size();
688         }
689 
690         @Override
691         public long getGroupId(int groupPosition) {
692             return groupPosition;
693         }
694 
695         @Override
696         public boolean hasStableIds() {
697             return true;
698         }
699 
700         @Override
701         public boolean isChildSelectable(int groupPosition, int childPosition) {
702             return true;
703         }
704     }
705 
706     /**
707      * Handle any clicks on {@link ExpandableListAdapter} children, which
708      * usually mean toggling its visible state.
709      */
710     @Override
711     public boolean onChildClick(ExpandableListView parent, View view, int groupPosition,
712             int childPosition, long id) {
713         final CheckBox checkbox = (CheckBox)view.findViewById(android.R.id.checkbox);
714 
715         final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
716         final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
717         if (child != null) {
718             checkbox.toggle();
719             child.putVisible(checkbox.isChecked());
720         } else {
721             // Open context menu for bringing back unsynced
722             this.openContextMenu(view);
723         }
724         return true;
725     }
726 
727     // TODO: move these definitions to framework constants when we begin
728     // defining this mode through <sync-adapter> tags
729     private static final int SYNC_MODE_UNSUPPORTED = 0;
730     private static final int SYNC_MODE_UNGROUPED = 1;
731     private static final int SYNC_MODE_EVERYTHING = 2;
732 
733     protected int getSyncMode(AccountDisplay account) {
734         // TODO: read sync mode through <sync-adapter> definition
735         if (GoogleAccountType.ACCOUNT_TYPE.equals(account.mType) && account.mDataSet == null) {
736             return SYNC_MODE_EVERYTHING;
737         } else {
738             return SYNC_MODE_UNSUPPORTED;
739         }
740     }
741 
742     @Override
743     public void onCreateContextMenu(ContextMenu menu, View view,
744             ContextMenu.ContextMenuInfo menuInfo) {
745         super.onCreateContextMenu(menu, view, menuInfo);
746 
747         // Bail if not working with expandable long-press, or if not child
748         if (!(menuInfo instanceof ExpandableListContextMenuInfo)) return;
749 
750         final ExpandableListContextMenuInfo info = (ExpandableListContextMenuInfo) menuInfo;
751         final int groupPosition = ExpandableListView.getPackedPositionGroup(info.packedPosition);
752         final int childPosition = ExpandableListView.getPackedPositionChild(info.packedPosition);
753 
754         // Skip long-press on expandable parents
755         if (childPosition == -1) return;
756 
757         final AccountDisplay account = (AccountDisplay)mAdapter.getGroup(groupPosition);
758         final GroupDelta child = (GroupDelta)mAdapter.getChild(groupPosition, childPosition);
759 
760         // Ignore when selective syncing unsupported
761         final int syncMode = getSyncMode(account);
762         if (syncMode == SYNC_MODE_UNSUPPORTED) return;
763 
764         if (child != null) {
765             showRemoveSync(menu, account, child, syncMode);
766         } else {
767             showAddSync(menu, account, syncMode);
768         }
769     }
770 
771     protected void showRemoveSync(ContextMenu menu, final AccountDisplay account,
772             final GroupDelta child, final int syncMode) {
773         final CharSequence title = child.getTitle(this);
774 
775         menu.setHeaderTitle(title);
776         menu.add(R.string.menu_sync_remove).setOnMenuItemClickListener(
777                 new OnMenuItemClickListener() {
778                     public boolean onMenuItemClick(MenuItem item) {
779                         handleRemoveSync(account, child, syncMode, title);
780                         return true;
781                     }
782                 });
783     }
784 
785     protected void handleRemoveSync(final AccountDisplay account, final GroupDelta child,
786             final int syncMode, CharSequence title) {
787         final boolean shouldSyncUngrouped = account.mUngrouped.getShouldSync();
788         if (syncMode == SYNC_MODE_EVERYTHING && shouldSyncUngrouped
789                 && !child.equals(account.mUngrouped)) {
790             // Warn before removing this group when it would cause ungrouped to stop syncing
791             final AlertDialog.Builder builder = new AlertDialog.Builder(this);
792             final CharSequence removeMessage = this.getString(
793                     R.string.display_warn_remove_ungrouped, title);
794             builder.setTitle(R.string.menu_sync_remove);
795             builder.setMessage(removeMessage);
796             builder.setNegativeButton(android.R.string.cancel, null);
797             builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
798                 public void onClick(DialogInterface dialog, int which) {
799                     // Mark both this group and ungrouped to stop syncing
800                     account.setShouldSync(account.mUngrouped, false);
801                     account.setShouldSync(child, false);
802                     mAdapter.notifyDataSetChanged();
803                 }
804             });
805             builder.show();
806         } else {
807             // Mark this group to not sync
808             account.setShouldSync(child, false);
809             mAdapter.notifyDataSetChanged();
810         }
811     }
812 
813     protected void showAddSync(ContextMenu menu, final AccountDisplay account, final int syncMode) {
814         menu.setHeaderTitle(R.string.dialog_sync_add);
815 
816         // Create item for each available, unsynced group
817         for (final GroupDelta child : account.mUnsyncedGroups) {
818             if (!child.getShouldSync()) {
819                 final CharSequence title = child.getTitle(this);
820                 menu.add(title).setOnMenuItemClickListener(new OnMenuItemClickListener() {
821                     public boolean onMenuItemClick(MenuItem item) {
822                         // Adding specific group for syncing
823                         if (child.mUngrouped && syncMode == SYNC_MODE_EVERYTHING) {
824                             account.setShouldSync(true);
825                         } else {
826                             account.setShouldSync(child, true);
827                         }
828                         mAdapter.notifyDataSetChanged();
829                         return true;
830                     }
831                 });
832             }
833         }
834     }
835 
836     private boolean hasUnsavedChanges() {
837         if (mAdapter == null || mAdapter.mAccounts == null) {
838             return false;
839         }
840         if (getCurrentListFilterType() != ContactListFilter.FILTER_TYPE_CUSTOM) {
841             return true;
842         }
843         final ArrayList<ContentProviderOperation> diff = mAdapter.mAccounts.buildDiff();
844         if (diff.isEmpty()) {
845             return false;
846         }
847         return true;
848     }
849 
850     @SuppressWarnings("unchecked")
851     private void doSaveAction() {
852         if (mAdapter == null || mAdapter.mAccounts == null) {
853             finish();
854             return;
855         }
856 
857         setResult(RESULT_OK);
858 
859         final ArrayList<ContentProviderOperation> diff = mAdapter.mAccounts.buildDiff();
860         if (diff.isEmpty()) {
861             finish();
862             return;
863         }
864 
865         new UpdateTask(this).execute(diff);
866     }
867 
868     /**
869      * Background task that persists changes to {@link Groups#GROUP_VISIBLE},
870      * showing spinner dialog to user while updating.
871      */
872     public static class UpdateTask extends
873             WeakAsyncTask<ArrayList<ContentProviderOperation>, Void, Void, Activity> {
874         private ProgressDialog mProgress;
875 
876         public UpdateTask(Activity target) {
877             super(target);
878         }
879 
880         /** {@inheritDoc} */
881         @Override
882         protected void onPreExecute(Activity target) {
883             final Context context = target;
884 
885             mProgress = ProgressDialog.show(
886                     context, null, context.getText(R.string.savingDisplayGroups));
887 
888             // Before starting this task, start an empty service to protect our
889             // process from being reclaimed by the system.
890             context.startService(new Intent(context, EmptyService.class));
891         }
892 
893         /** {@inheritDoc} */
894         @Override
895         protected Void doInBackground(
896                 Activity target, ArrayList<ContentProviderOperation>... params) {
897             final Context context = target;
898             final ContentValues values = new ContentValues();
899             final ContentResolver resolver = context.getContentResolver();
900 
901             try {
902                 final ArrayList<ContentProviderOperation> diff = params[0];
903                 resolver.applyBatch(ContactsContract.AUTHORITY, diff);
904             } catch (RemoteException e) {
905                 Log.e(TAG, "Problem saving display groups", e);
906             } catch (OperationApplicationException e) {
907                 Log.e(TAG, "Problem saving display groups", e);
908             }
909 
910             return null;
911         }
912 
913         /** {@inheritDoc} */
914         @Override
915         protected void onPostExecute(Activity target, Void result) {
916             final Context context = target;
917 
918             try {
919                 mProgress.dismiss();
920             } catch (Exception e) {
921                 Log.e(TAG, "Error dismissing progress dialog", e);
922             }
923 
924             target.finish();
925 
926             // Stop the service that was protecting us
927             context.stopService(new Intent(context, EmptyService.class));
928         }
929     }
930 
931     @Override
932     public boolean onCreateOptionsMenu(Menu menu) {
933         super.onCreateOptionsMenu(menu);
934 
935         final MenuItem menuItem = menu.add(Menu.NONE, R.id.menu_save, Menu.NONE,
936                 R.string.menu_custom_filter_save);
937         menuItem.setShowAsAction(MenuItem.SHOW_AS_ACTION_ALWAYS);
938 
939         return true;
940     }
941 
942     @Override
943     public boolean onOptionsItemSelected(MenuItem item) {
944         final int id = item.getItemId();
945         if (id == android.R.id.home) {
946             confirmFinish();
947             return true;
948         } else if (id == R.id.menu_save) {
949             this.doSaveAction();
950             return true;
951         } else {
952         }
953         return super.onOptionsItemSelected(item);
954     }
955 
956     @Override
957     public void onBackPressed() {
958         confirmFinish();
959     }
960 
961     private void confirmFinish() {
962         // Prompt the user whether they want to discard there customizations unless
963         // nothing will be changed.
964         if (hasUnsavedChanges()) {
965             new ConfirmNavigationDialogFragment().show(getFragmentManager(),
966                     "ConfirmNavigationDialog");
967         } else {
968             setResult(RESULT_CANCELED);
969             finish();
970         }
971     }
972 
973     private int getCurrentListFilterType() {
974         return getIntent().getIntExtra(EXTRA_CURRENT_LIST_FILTER_TYPE,
975                 ContactListFilter.FILTER_TYPE_ALL_ACCOUNTS);
976     }
977 
978     public static class ConfirmNavigationDialogFragment
979             extends DialogFragment implements DialogInterface.OnClickListener {
980 
981         @Override
982         public Dialog onCreateDialog(Bundle savedInstanceState) {
983             return new AlertDialog.Builder(getActivity(), getTheme())
984                     .setMessage(R.string.leave_customize_confirmation_dialog_message)
985                     .setNegativeButton(android.R.string.no, null)
986                     .setPositiveButton(android.R.string.yes, this)
987                     .create();
988         }
989 
990         @Override
991         public void onClick(DialogInterface dialogInterface, int i) {
992             if (i == DialogInterface.BUTTON_POSITIVE) {
993                 getActivity().setResult(RESULT_CANCELED);
994                 getActivity().finish();
995             }
996         }
997     }
998 }
999