GroupEditorFragment.java revision f30723782e801deaf159aea2443e9507596ef11d
1/*
2 * Copyright (C) 2011 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.group;
18
19import com.android.contacts.ContactPhotoManager;
20import com.android.contacts.ContactSaveService;
21import com.android.contacts.GroupMemberLoader;
22import com.android.contacts.GroupMemberLoader.GroupEditorQuery;
23import com.android.contacts.GroupMetaDataLoader;
24import com.android.contacts.R;
25import com.android.contacts.activities.GroupEditorActivity;
26import com.android.contacts.editor.SelectAccountDialogFragment;
27import com.android.contacts.group.SuggestedMemberListAdapter.SuggestedMember;
28import com.android.contacts.model.AccountType;
29import com.android.contacts.model.AccountTypeManager;
30import com.android.contacts.model.AccountWithDataSet;
31import com.android.contacts.util.AccountsListAdapter.AccountListFilter;
32import com.android.internal.util.Objects;
33
34import android.accounts.Account;
35import android.app.Activity;
36import android.app.AlertDialog;
37import android.app.Dialog;
38import android.app.DialogFragment;
39import android.app.Fragment;
40import android.app.LoaderManager;
41import android.app.LoaderManager.LoaderCallbacks;
42import android.content.ContentResolver;
43import android.content.ContentUris;
44import android.content.Context;
45import android.content.CursorLoader;
46import android.content.DialogInterface;
47import android.content.Intent;
48import android.content.Loader;
49import android.database.Cursor;
50import android.net.Uri;
51import android.os.Bundle;
52import android.os.Parcel;
53import android.os.Parcelable;
54import android.provider.ContactsContract.Contacts;
55import android.provider.ContactsContract.Intents;
56import android.text.TextUtils;
57import android.util.Log;
58import android.view.LayoutInflater;
59import android.view.Menu;
60import android.view.MenuInflater;
61import android.view.MenuItem;
62import android.view.View;
63import android.view.View.OnClickListener;
64import android.view.ViewGroup;
65import android.widget.AdapterView;
66import android.widget.AdapterView.OnItemClickListener;
67import android.widget.AutoCompleteTextView;
68import android.widget.BaseAdapter;
69import android.widget.ImageView;
70import android.widget.ListView;
71import android.widget.QuickContactBadge;
72import android.widget.TextView;
73import android.widget.Toast;
74
75import java.util.ArrayList;
76import java.util.List;
77
78public class GroupEditorFragment extends Fragment implements SelectAccountDialogFragment.Listener {
79    private static final String TAG = "GroupEditorFragment";
80
81    private static final String LEGACY_CONTACTS_AUTHORITY = "contacts";
82
83    private static final String KEY_ACTION = "action";
84    private static final String KEY_GROUP_URI = "groupUri";
85    private static final String KEY_GROUP_ID = "groupId";
86    private static final String KEY_STATUS = "status";
87    private static final String KEY_ACCOUNT_NAME = "accountName";
88    private static final String KEY_ACCOUNT_TYPE = "accountType";
89    private static final String KEY_DATA_SET = "dataSet";
90    private static final String KEY_GROUP_NAME_IS_READ_ONLY = "groupNameIsReadOnly";
91    private static final String KEY_ORIGINAL_GROUP_NAME = "originalGroupName";
92    private static final String KEY_MEMBERS_TO_ADD = "membersToAdd";
93    private static final String KEY_MEMBERS_TO_REMOVE = "membersToRemove";
94    private static final String KEY_MEMBERS_TO_DISPLAY = "membersToDisplay";
95
96    private static final String CURRENT_EDITOR_TAG = "currentEditorForAccount";
97
98    public static interface Listener {
99        /**
100         * Group metadata was not found, close the fragment now.
101         */
102        public void onGroupNotFound();
103
104        /**
105         * User has tapped Revert, close the fragment now.
106         */
107        void onReverted();
108
109        /**
110         * Contact was saved and the Fragment can now be closed safely.
111         */
112        void onSaveFinished(int resultCode, Intent resultIntent);
113
114        /**
115         * Fragment is created but there's no accounts set up.
116         */
117        void onAccountsNotFound();
118    }
119
120    private static final int LOADER_GROUP_METADATA = 1;
121    private static final int LOADER_EXISTING_MEMBERS = 2;
122    private static final int LOADER_NEW_GROUP_MEMBER = 3;
123
124    private static final String MEMBER_RAW_CONTACT_ID_KEY = "rawContactId";
125    private static final String MEMBER_LOOKUP_URI_KEY = "memberLookupUri";
126
127    protected static final String[] PROJECTION_CONTACT = new String[] {
128        Contacts._ID,                           // 0
129        Contacts.DISPLAY_NAME_PRIMARY,          // 1
130        Contacts.DISPLAY_NAME_ALTERNATIVE,      // 2
131        Contacts.SORT_KEY_PRIMARY,              // 3
132        Contacts.STARRED,                       // 4
133        Contacts.CONTACT_PRESENCE,              // 5
134        Contacts.CONTACT_CHAT_CAPABILITY,       // 6
135        Contacts.PHOTO_ID,                      // 7
136        Contacts.PHOTO_THUMBNAIL_URI,           // 8
137        Contacts.LOOKUP_KEY,                    // 9
138        Contacts.PHONETIC_NAME,                 // 10
139        Contacts.HAS_PHONE_NUMBER,              // 11
140        Contacts.IS_USER_PROFILE,               // 12
141    };
142
143    protected static final int CONTACT_ID_COLUMN_INDEX = 0;
144    protected static final int CONTACT_DISPLAY_NAME_PRIMARY_COLUMN_INDEX = 1;
145    protected static final int CONTACT_DISPLAY_NAME_ALTERNATIVE_COLUMN_INDEX = 2;
146    protected static final int CONTACT_SORT_KEY_PRIMARY_COLUMN_INDEX = 3;
147    protected static final int CONTACT_STARRED_COLUMN_INDEX = 4;
148    protected static final int CONTACT_PRESENCE_STATUS_COLUMN_INDEX = 5;
149    protected static final int CONTACT_CHAT_CAPABILITY_COLUMN_INDEX = 6;
150    protected static final int CONTACT_PHOTO_ID_COLUMN_INDEX = 7;
151    protected static final int CONTACT_PHOTO_URI_COLUMN_INDEX = 8;
152    protected static final int CONTACT_LOOKUP_KEY_COLUMN_INDEX = 9;
153    protected static final int CONTACT_PHONETIC_NAME_COLUMN_INDEX = 10;
154    protected static final int CONTACT_HAS_PHONE_COLUMN_INDEX = 11;
155    protected static final int CONTACT_IS_USER_PROFILE = 12;
156
157    /**
158     * Modes that specify the status of the editor
159     */
160    public enum Status {
161        SELECTING_ACCOUNT, // Account select dialog is showing
162        LOADING,    // Loader is fetching the group metadata
163        EDITING,    // Not currently busy. We are waiting forthe user to enter data.
164        SAVING,     // Data is currently being saved
165        CLOSING     // Prevents any more saves
166    }
167
168    private Context mContext;
169    private String mAction;
170    private Bundle mIntentExtras;
171    private Uri mGroupUri;
172    private long mGroupId;
173    private Listener mListener;
174
175    private Status mStatus;
176
177    private ViewGroup mRootView;
178    private ListView mListView;
179    private LayoutInflater mLayoutInflater;
180
181    private TextView mGroupNameView;
182    private AutoCompleteTextView mAutoCompleteTextView;
183
184    private String mAccountName;
185    private String mAccountType;
186    private String mDataSet;
187
188    private boolean mGroupNameIsReadOnly;
189    private String mOriginalGroupName = "";
190    private int mLastGroupEditorId;
191
192    private MemberListAdapter mMemberListAdapter;
193    private ContactPhotoManager mPhotoManager;
194
195    private ContentResolver mContentResolver;
196    private SuggestedMemberListAdapter mAutoCompleteAdapter;
197
198    private ArrayList<Member> mListMembersToAdd = new ArrayList<Member>();
199    private ArrayList<Member> mListMembersToRemove = new ArrayList<Member>();
200    private ArrayList<Member> mListToDisplay = new ArrayList<Member>();
201
202    public GroupEditorFragment() {
203    }
204
205    @Override
206    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
207        setHasOptionsMenu(true);
208        mLayoutInflater = inflater;
209        mRootView = (ViewGroup) inflater.inflate(R.layout.group_editor_fragment, container, false);
210        return mRootView;
211    }
212
213    @Override
214    public void onAttach(Activity activity) {
215        super.onAttach(activity);
216        mContext = activity;
217        mPhotoManager = ContactPhotoManager.getInstance(mContext);
218        mMemberListAdapter = new MemberListAdapter();
219    }
220
221    @Override
222    public void onActivityCreated(Bundle savedInstanceState) {
223        super.onActivityCreated(savedInstanceState);
224
225        if (savedInstanceState != null) {
226            // Just restore from the saved state.  No loading.
227            onRestoreInstanceState(savedInstanceState);
228            if (mStatus == Status.SELECTING_ACCOUNT) {
229                // Account select dialog is showing.  Don't setup the editor yet.
230            } else if (mStatus == Status.LOADING) {
231                startGroupMetaDataLoader();
232            } else {
233                setupEditorForAccount();
234            }
235        } else if (Intent.ACTION_EDIT.equals(mAction)) {
236            startGroupMetaDataLoader();
237        } else if (Intent.ACTION_INSERT.equals(mAction)) {
238            final Account account = mIntentExtras == null ? null :
239                    (Account) mIntentExtras.getParcelable(Intents.Insert.ACCOUNT);
240            final String dataSet = mIntentExtras == null ? null :
241                    mIntentExtras.getString(Intents.Insert.DATA_SET);
242
243            if (account != null) {
244                // Account specified in Intent - no data set can be specified in this manner.
245                mAccountName = account.name;
246                mAccountType = account.type;
247                mDataSet = dataSet;
248                setupEditorForAccount();
249            } else {
250                // No Account specified. Let the user choose from a disambiguation dialog.
251                selectAccountAndCreateGroup();
252            }
253        } else {
254            throw new IllegalArgumentException("Unknown Action String " + mAction +
255                    ". Only support " + Intent.ACTION_EDIT + " or " + Intent.ACTION_INSERT);
256        }
257    }
258
259    private void startGroupMetaDataLoader() {
260        mStatus = Status.LOADING;
261        getLoaderManager().initLoader(LOADER_GROUP_METADATA, null,
262                mGroupMetaDataLoaderListener);
263    }
264
265    @Override
266    public void onSaveInstanceState(Bundle outState) {
267        super.onSaveInstanceState(outState);
268        outState.putString(KEY_ACTION, mAction);
269        outState.putParcelable(KEY_GROUP_URI, mGroupUri);
270        outState.putLong(KEY_GROUP_ID, mGroupId);
271
272        outState.putSerializable(KEY_STATUS, mStatus);
273        outState.putString(KEY_ACCOUNT_NAME, mAccountName);
274        outState.putString(KEY_ACCOUNT_TYPE, mAccountType);
275        outState.putString(KEY_DATA_SET, mDataSet);
276
277        outState.putBoolean(KEY_GROUP_NAME_IS_READ_ONLY, mGroupNameIsReadOnly);
278        outState.putString(KEY_ORIGINAL_GROUP_NAME, mOriginalGroupName);
279
280        outState.putParcelableArrayList(KEY_MEMBERS_TO_ADD, mListMembersToAdd);
281        outState.putParcelableArrayList(KEY_MEMBERS_TO_REMOVE, mListMembersToRemove);
282        outState.putParcelableArrayList(KEY_MEMBERS_TO_DISPLAY, mListToDisplay);
283    }
284
285    private void onRestoreInstanceState(Bundle state) {
286        mAction = state.getString(KEY_ACTION);
287        mGroupUri = state.getParcelable(KEY_GROUP_URI);
288        mGroupId = state.getLong(KEY_GROUP_ID);
289
290        mStatus = (Status) state.getSerializable(KEY_STATUS);
291        mAccountName = state.getString(KEY_ACCOUNT_NAME);
292        mAccountType = state.getString(KEY_ACCOUNT_TYPE);
293        mDataSet = state.getString(KEY_DATA_SET);
294
295        mGroupNameIsReadOnly = state.getBoolean(KEY_GROUP_NAME_IS_READ_ONLY);
296        mOriginalGroupName = state.getString(KEY_ORIGINAL_GROUP_NAME);
297
298        mListMembersToAdd = state.getParcelableArrayList(KEY_MEMBERS_TO_ADD);
299        mListMembersToRemove = state.getParcelableArrayList(KEY_MEMBERS_TO_REMOVE);
300        mListToDisplay = state.getParcelableArrayList(KEY_MEMBERS_TO_DISPLAY);
301    }
302
303    public void setContentResolver(ContentResolver resolver) {
304        mContentResolver = resolver;
305        if (mAutoCompleteAdapter != null) {
306            mAutoCompleteAdapter.setContentResolver(mContentResolver);
307        }
308    }
309
310    private void selectAccountAndCreateGroup() {
311        final List<AccountWithDataSet> accounts =
312                AccountTypeManager.getInstance(mContext).getAccounts(true /* writeable */);
313        // No Accounts available
314        if (accounts.isEmpty()) {
315            Log.e(TAG, "No accounts were found.");
316            if (mListener != null) {
317                mListener.onAccountsNotFound();
318            }
319            return;
320        }
321
322        // In the common case of a single account being writable, auto-select
323        // it without showing a dialog.
324        if (accounts.size() == 1) {
325            mAccountName = accounts.get(0).name;
326            mAccountType = accounts.get(0).type;
327            mDataSet = accounts.get(0).dataSet;
328            setupEditorForAccount();
329            return;  // Don't show a dialog.
330        }
331
332        mStatus = Status.SELECTING_ACCOUNT;
333        SelectAccountDialogFragment.show(getFragmentManager(), this,
334                R.string.dialog_new_group_account, AccountListFilter.ACCOUNTS_GROUP_WRITABLE,
335                null);
336    }
337
338    @Override
339    public void onAccountChosen(AccountWithDataSet account, Bundle extraArgs) {
340        mAccountName = account.name;
341        mAccountType = account.type;
342        mDataSet = account.dataSet;
343        setupEditorForAccount();
344    }
345
346    @Override
347    public void onAccountSelectorCancelled() {
348        if (mListener != null) {
349            // Exit the fragment because we cannot continue without selecting an account
350            mListener.onGroupNotFound();
351        }
352    }
353
354    private AccountType getAccountType() {
355        return AccountTypeManager.getInstance(mContext).getAccountType(mAccountType, mDataSet);
356    }
357
358    /**
359     * @return true if the group membership is editable on this account type.  false otherwise,
360     *         or account is not set yet.
361     */
362    private boolean isGroupMembershipEditable() {
363        if (mAccountType == null) {
364            return false;
365        }
366        return getAccountType().isGroupMembershipEditable();
367    }
368
369    /**
370     * Sets up the editor based on the group's account name and type.
371     */
372    private void setupEditorForAccount() {
373        final AccountType accountType = getAccountType();
374        final boolean editable = isGroupMembershipEditable();
375        boolean isNewEditor = false;
376        mMemberListAdapter.setIsGroupMembershipEditable(editable);
377
378        // Since this method can be called multiple time, remove old editor if the editor type
379        // is different from the new one and mark the editor with a tag so it can be found for
380        // removal if needed
381        View editorView;
382        int newGroupEditorId =
383                editable ? R.layout.group_editor_view : R.layout.external_group_editor_view;
384        if (newGroupEditorId != mLastGroupEditorId) {
385            View oldEditorView = mRootView.findViewWithTag(CURRENT_EDITOR_TAG);
386            if (oldEditorView != null) {
387                mRootView.removeView(oldEditorView);
388            }
389            editorView = mLayoutInflater.inflate(newGroupEditorId, mRootView, false);
390            editorView.setTag(CURRENT_EDITOR_TAG);
391            mAutoCompleteAdapter = null;
392            mLastGroupEditorId = newGroupEditorId;
393            isNewEditor = true;
394        } else {
395            editorView = mRootView.findViewWithTag(CURRENT_EDITOR_TAG);
396            if (editorView == null) {
397                throw new IllegalStateException("Group editor view not found");
398            }
399        }
400
401        mGroupNameView = (TextView) editorView.findViewById(R.id.group_name);
402        mAutoCompleteTextView = (AutoCompleteTextView) editorView.findViewById(
403                R.id.add_member_field);
404
405        mListView = (ListView) editorView.findViewById(android.R.id.list);
406        mListView.setAdapter(mMemberListAdapter);
407
408        // Setup the account header, only when exists.
409        if (editorView.findViewById(R.id.account_header) != null) {
410            CharSequence accountTypeDisplayLabel = accountType.getDisplayLabel(mContext);
411            ImageView accountIcon = (ImageView) editorView.findViewById(R.id.account_icon);
412            TextView accountTypeTextView = (TextView) editorView.findViewById(R.id.account_type);
413            TextView accountNameTextView = (TextView) editorView.findViewById(R.id.account_name);
414            if (!TextUtils.isEmpty(mAccountName)) {
415                accountNameTextView.setText(
416                        mContext.getString(R.string.from_account_format, mAccountName));
417            }
418            accountTypeTextView.setText(accountTypeDisplayLabel);
419            accountIcon.setImageDrawable(accountType.getDisplayIcon(mContext));
420        }
421
422        // Setup the autocomplete adapter (for contacts to suggest to add to the group) based on the
423        // account name and type. For groups that cannot have membership edited, there will be no
424        // autocomplete text view.
425        if (mAutoCompleteTextView != null) {
426            mAutoCompleteAdapter = new SuggestedMemberListAdapter(mContext,
427                    android.R.layout.simple_dropdown_item_1line);
428            mAutoCompleteAdapter.setContentResolver(mContentResolver);
429            mAutoCompleteAdapter.setAccountType(mAccountType);
430            mAutoCompleteAdapter.setAccountName(mAccountName);
431            mAutoCompleteAdapter.setDataSet(mDataSet);
432            mAutoCompleteTextView.setAdapter(mAutoCompleteAdapter);
433            mAutoCompleteTextView.setOnItemClickListener(new OnItemClickListener() {
434                @Override
435                public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
436                    SuggestedMember member = (SuggestedMember) view.getTag();
437                    if (member == null) {
438                        return; // just in case
439                    }
440                    loadMemberToAddToGroup(member.getRawContactId(),
441                            String.valueOf(member.getContactId()));
442
443                    // Update the autocomplete adapter so the contact doesn't get suggested again
444                    mAutoCompleteAdapter.addNewMember(member.getContactId());
445
446                    // Clear out the text field
447                    mAutoCompleteTextView.setText("");
448                }
449            });
450            // Update the exempt list.  (mListToDisplay might have been restored from the saved
451            // state.)
452            mAutoCompleteAdapter.updateExistingMembersList(mListToDisplay);
453        }
454
455        // If the group name is ready only, don't let the user focus on the field.
456        mGroupNameView.setFocusable(!mGroupNameIsReadOnly);
457        if(isNewEditor) {
458            mRootView.addView(editorView);
459        }
460        mStatus = Status.EDITING;
461    }
462
463    public void load(String action, Uri groupUri, Bundle intentExtras) {
464        mAction = action;
465        mGroupUri = groupUri;
466        mGroupId = (groupUri != null) ? ContentUris.parseId(mGroupUri) : 0;
467        mIntentExtras = intentExtras;
468    }
469
470    private void bindGroupMetaData(Cursor cursor) {
471        if (!cursor.moveToFirst()) {
472            Log.i(TAG, "Group not found with URI: " + mGroupUri + " Closing activity now.");
473            if (mListener != null) {
474                mListener.onGroupNotFound();
475            }
476            return;
477        }
478        mOriginalGroupName = cursor.getString(GroupMetaDataLoader.TITLE);
479        mAccountName = cursor.getString(GroupMetaDataLoader.ACCOUNT_NAME);
480        mAccountType = cursor.getString(GroupMetaDataLoader.ACCOUNT_TYPE);
481        mDataSet = cursor.getString(GroupMetaDataLoader.DATA_SET);
482        mGroupNameIsReadOnly = (cursor.getInt(GroupMetaDataLoader.IS_READ_ONLY) == 1);
483        setupEditorForAccount();
484
485        // Setup the group metadata display
486        mGroupNameView.setText(mOriginalGroupName);
487    }
488
489    public void loadMemberToAddToGroup(long rawContactId, String contactId) {
490        Bundle args = new Bundle();
491        args.putLong(MEMBER_RAW_CONTACT_ID_KEY, rawContactId);
492        args.putString(MEMBER_LOOKUP_URI_KEY, contactId);
493        getLoaderManager().restartLoader(LOADER_NEW_GROUP_MEMBER, args, mContactLoaderListener);
494    }
495
496    public void setListener(Listener value) {
497        mListener = value;
498    }
499
500    public void onDoneClicked() {
501        if (isGroupMembershipEditable()) {
502            save();
503        } else {
504            // Just revert it.
505            doRevertAction();
506        }
507    }
508
509    @Override
510    public void onCreateOptionsMenu(Menu menu, final MenuInflater inflater) {
511        inflater.inflate(R.menu.edit_group, menu);
512    }
513
514    @Override
515    public boolean onOptionsItemSelected(MenuItem item) {
516        switch (item.getItemId()) {
517            case R.id.menu_discard:
518                return revert();
519        }
520        return false;
521    }
522
523    private boolean revert() {
524        if (!hasNameChange() && !hasMembershipChange()) {
525            doRevertAction();
526        } else {
527            CancelEditDialogFragment.show(this);
528        }
529        return true;
530    }
531
532    private void doRevertAction() {
533        // When this Fragment is closed we don't want it to auto-save
534        mStatus = Status.CLOSING;
535        if (mListener != null) mListener.onReverted();
536    }
537
538    public static class CancelEditDialogFragment extends DialogFragment {
539
540        public static void show(GroupEditorFragment fragment) {
541            CancelEditDialogFragment dialog = new CancelEditDialogFragment();
542            dialog.setTargetFragment(fragment, 0);
543            dialog.show(fragment.getFragmentManager(), "cancelEditor");
544        }
545
546        @Override
547        public Dialog onCreateDialog(Bundle savedInstanceState) {
548            AlertDialog dialog = new AlertDialog.Builder(getActivity())
549                    .setIconAttribute(android.R.attr.alertDialogIcon)
550                    .setMessage(R.string.cancel_confirmation_dialog_message)
551                    .setPositiveButton(android.R.string.ok,
552                        new DialogInterface.OnClickListener() {
553                            @Override
554                            public void onClick(DialogInterface dialogInterface, int whichButton) {
555                                ((GroupEditorFragment) getTargetFragment()).doRevertAction();
556                            }
557                        }
558                    )
559                    .setNegativeButton(android.R.string.cancel, null)
560                    .create();
561            return dialog;
562        }
563    }
564
565    /**
566     * Saves or creates the group based on the mode, and if successful
567     * finishes the activity. This actually only handles saving the group name.
568     * @return true when successful
569     */
570    public boolean save() {
571        if (!hasValidGroupName() || mStatus != Status.EDITING) {
572            return false;
573        }
574
575        // If we are about to close the editor - there is no need to refresh the data
576        getLoaderManager().destroyLoader(LOADER_EXISTING_MEMBERS);
577
578        // If there are no changes, then go straight to onSaveCompleted()
579        if (!hasNameChange() && !hasMembershipChange()) {
580            onSaveCompleted(false, mGroupUri);
581            return true;
582        }
583
584        mStatus = Status.SAVING;
585
586        Activity activity = getActivity();
587        // If the activity is not there anymore, then we can't continue with the save process.
588        if (activity == null) {
589            return false;
590        }
591        Intent saveIntent = null;
592        if (Intent.ACTION_INSERT.equals(mAction)) {
593            // Create array of raw contact IDs for contacts to add to the group
594            long[] membersToAddArray = convertToArray(mListMembersToAdd);
595
596            // Create the save intent to create the group and add members at the same time
597            saveIntent = ContactSaveService.createNewGroupIntent(activity,
598                    new AccountWithDataSet(mAccountName, mAccountType, mDataSet),
599                    mGroupNameView.getText().toString(),
600                    membersToAddArray, activity.getClass(),
601                    GroupEditorActivity.ACTION_SAVE_COMPLETED);
602        } else if (Intent.ACTION_EDIT.equals(mAction)) {
603            // Create array of raw contact IDs for contacts to add to the group
604            long[] membersToAddArray = convertToArray(mListMembersToAdd);
605
606            // Create array of raw contact IDs for contacts to add to the group
607            long[] membersToRemoveArray = convertToArray(mListMembersToRemove);
608
609            // Create the update intent (which includes the updated group name if necessary)
610            saveIntent = ContactSaveService.createGroupUpdateIntent(activity, mGroupId,
611                    getUpdatedName(), membersToAddArray, membersToRemoveArray,
612                    activity.getClass(), GroupEditorActivity.ACTION_SAVE_COMPLETED);
613        } else {
614            throw new IllegalStateException("Invalid intent action type " + mAction);
615        }
616        activity.startService(saveIntent);
617        return true;
618    }
619
620    public void onSaveCompleted(boolean hadChanges, Uri groupUri) {
621        boolean success = groupUri != null;
622        Log.d(TAG, "onSaveCompleted(" + groupUri + ")");
623        if (hadChanges) {
624            Toast.makeText(mContext, success ? R.string.groupSavedToast :
625                    R.string.groupSavedErrorToast, Toast.LENGTH_SHORT).show();
626        }
627        final Intent resultIntent;
628        final int resultCode;
629        if (success && groupUri != null) {
630            final String requestAuthority = groupUri.getAuthority();
631
632            resultIntent = new Intent();
633            if (LEGACY_CONTACTS_AUTHORITY.equals(requestAuthority)) {
634                // Build legacy Uri when requested by caller
635                final long groupId = ContentUris.parseId(groupUri);
636                final Uri legacyContentUri = Uri.parse("content://contacts/groups");
637                final Uri legacyUri = ContentUris.withAppendedId(
638                        legacyContentUri, groupId);
639                resultIntent.setData(legacyUri);
640            } else {
641                // Otherwise pass back the given Uri
642                resultIntent.setData(groupUri);
643            }
644
645            resultCode = Activity.RESULT_OK;
646        } else {
647            resultCode = Activity.RESULT_CANCELED;
648            resultIntent = null;
649        }
650        // It is already saved, so prevent that it is saved again
651        mStatus = Status.CLOSING;
652        if (mListener != null) {
653            mListener.onSaveFinished(resultCode, resultIntent);
654        }
655    }
656
657    private boolean hasValidGroupName() {
658        return mGroupNameView != null && !TextUtils.isEmpty(mGroupNameView.getText());
659    }
660
661    private boolean hasNameChange() {
662        return mGroupNameView != null &&
663                !mGroupNameView.getText().toString().equals(mOriginalGroupName);
664    }
665
666    private boolean hasMembershipChange() {
667        return mListMembersToAdd.size() > 0 || mListMembersToRemove.size() > 0;
668    }
669
670    /**
671     * Returns the group's new name or null if there is no change from the
672     * original name that was loaded for the group.
673     */
674    private String getUpdatedName() {
675        String groupNameFromTextView = mGroupNameView.getText().toString();
676        if (groupNameFromTextView.equals(mOriginalGroupName)) {
677            // No name change, so return null
678            return null;
679        }
680        return groupNameFromTextView;
681    }
682
683    private static long[] convertToArray(List<Member> listMembers) {
684        int size = listMembers.size();
685        long[] membersArray = new long[size];
686        for (int i = 0; i < size; i++) {
687            membersArray[i] = listMembers.get(i).getRawContactId();
688        }
689        return membersArray;
690    }
691
692    private void addExistingMembers(List<Member> members) {
693
694        // Re-create the list to display
695        mListToDisplay.clear();
696        mListToDisplay.addAll(members);
697        mListToDisplay.addAll(mListMembersToAdd);
698        mListToDisplay.removeAll(mListMembersToRemove);
699        mMemberListAdapter.notifyDataSetChanged();
700
701
702        // Update the autocomplete adapter (if there is one) so these contacts don't get suggested
703        if (mAutoCompleteAdapter != null) {
704            mAutoCompleteAdapter.updateExistingMembersList(members);
705        }
706    }
707
708    private void addMember(Member member) {
709        // Update the display list
710        mListMembersToAdd.add(member);
711        mListToDisplay.add(member);
712        mMemberListAdapter.notifyDataSetChanged();
713
714        // Update the autocomplete adapter so the contact doesn't get suggested again
715        mAutoCompleteAdapter.addNewMember(member.getContactId());
716    }
717
718    private void removeMember(Member member) {
719        // If the contact was just added during this session, remove it from the list of
720        // members to add
721        if (mListMembersToAdd.contains(member)) {
722            mListMembersToAdd.remove(member);
723        } else {
724            // Otherwise this contact was already part of the existing list of contacts,
725            // so we need to do a content provider deletion operation
726            mListMembersToRemove.add(member);
727        }
728        // In either case, update the UI so the contact is no longer in the list of
729        // members
730        mListToDisplay.remove(member);
731        mMemberListAdapter.notifyDataSetChanged();
732
733        // Update the autocomplete adapter so the contact can get suggested again
734        mAutoCompleteAdapter.removeMember(member.getContactId());
735    }
736
737    /**
738     * The listener for the group metadata (i.e. group name, account type, and account name) loader.
739     */
740    private final LoaderManager.LoaderCallbacks<Cursor> mGroupMetaDataLoaderListener =
741            new LoaderCallbacks<Cursor>() {
742
743        @Override
744        public CursorLoader onCreateLoader(int id, Bundle args) {
745            return new GroupMetaDataLoader(mContext, mGroupUri);
746        }
747
748        @Override
749        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
750            bindGroupMetaData(data);
751
752            // Load existing members
753            getLoaderManager().initLoader(LOADER_EXISTING_MEMBERS, null,
754                    mGroupMemberListLoaderListener);
755        }
756
757        @Override
758        public void onLoaderReset(Loader<Cursor> loader) {}
759    };
760
761    /**
762     * The loader listener for the list of existing group members.
763     */
764    private final LoaderManager.LoaderCallbacks<Cursor> mGroupMemberListLoaderListener =
765            new LoaderCallbacks<Cursor>() {
766
767        @Override
768        public CursorLoader onCreateLoader(int id, Bundle args) {
769            return GroupMemberLoader.constructLoaderForGroupEditorQuery(mContext, mGroupId);
770        }
771
772        @Override
773        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
774            List<Member> listExistingMembers = new ArrayList<Member>();
775            data.moveToPosition(-1);
776            while (data.moveToNext()) {
777                long contactId = data.getLong(GroupEditorQuery.CONTACT_ID);
778                long rawContactId = data.getLong(GroupEditorQuery.RAW_CONTACT_ID);
779                String lookupKey = data.getString(GroupEditorQuery.CONTACT_LOOKUP_KEY);
780                String displayName = data.getString(GroupEditorQuery.CONTACT_DISPLAY_NAME_PRIMARY);
781                String photoUri = data.getString(GroupEditorQuery.CONTACT_PHOTO_URI);
782                listExistingMembers.add(new Member(rawContactId, lookupKey, contactId,
783                        displayName, photoUri));
784            }
785
786            // Update the display list
787            addExistingMembers(listExistingMembers);
788
789            // No more updates
790            // TODO: move to a runnable
791            getLoaderManager().destroyLoader(LOADER_EXISTING_MEMBERS);
792        }
793
794        @Override
795        public void onLoaderReset(Loader<Cursor> loader) {}
796    };
797
798    /**
799     * The listener to load a summary of details for a contact.
800     */
801    // TODO: Remove this step because showing the aggregate contact can be confusing when the user
802    // just selected a raw contact
803    private final LoaderManager.LoaderCallbacks<Cursor> mContactLoaderListener =
804            new LoaderCallbacks<Cursor>() {
805
806        private long mRawContactId;
807
808        @Override
809        public CursorLoader onCreateLoader(int id, Bundle args) {
810            String memberId = args.getString(MEMBER_LOOKUP_URI_KEY);
811            mRawContactId = args.getLong(MEMBER_RAW_CONTACT_ID_KEY);
812            return new CursorLoader(mContext, Uri.withAppendedPath(Contacts.CONTENT_URI, memberId),
813                    PROJECTION_CONTACT, null, null, null);
814        }
815
816        @Override
817        public void onLoadFinished(Loader<Cursor> loader, Cursor cursor) {
818            if (!cursor.moveToFirst()) {
819                return;
820            }
821            // Retrieve the contact data fields that will be sufficient to update the adapter with
822            // a new entry for this contact
823            long contactId = cursor.getLong(CONTACT_ID_COLUMN_INDEX);
824            String displayName = cursor.getString(CONTACT_DISPLAY_NAME_PRIMARY_COLUMN_INDEX);
825            String lookupKey = cursor.getString(CONTACT_LOOKUP_KEY_COLUMN_INDEX);
826            String photoUri = cursor.getString(CONTACT_PHOTO_URI_COLUMN_INDEX);
827            getLoaderManager().destroyLoader(LOADER_NEW_GROUP_MEMBER);
828            Member member = new Member(mRawContactId, lookupKey, contactId, displayName, photoUri);
829            addMember(member);
830        }
831
832        @Override
833        public void onLoaderReset(Loader<Cursor> loader) {}
834    };
835
836    /**
837     * This represents a single member of the current group.
838     */
839    public static class Member implements Parcelable {
840
841        // TODO: Switch to just dealing with raw contact IDs everywhere if possible
842        private final long mRawContactId;
843        private final long mContactId;
844        private final Uri mLookupUri;
845        private final String mDisplayName;
846        private final Uri mPhotoUri;
847
848        public Member(long rawContactId, String lookupKey, long contactId, String displayName,
849                String photoUri) {
850            mRawContactId = rawContactId;
851            mContactId = contactId;
852            mLookupUri = Contacts.getLookupUri(contactId, lookupKey);
853            mDisplayName = displayName;
854            mPhotoUri = (photoUri != null) ? Uri.parse(photoUri) : null;
855        }
856
857        public long getRawContactId() {
858            return mRawContactId;
859        }
860
861        public long getContactId() {
862            return mContactId;
863        }
864
865        public Uri getLookupUri() {
866            return mLookupUri;
867        }
868
869        public String getDisplayName() {
870            return mDisplayName;
871        }
872
873        public Uri getPhotoUri() {
874            return mPhotoUri;
875        }
876
877        @Override
878        public boolean equals(Object object) {
879            if (object instanceof Member) {
880                Member otherMember = (Member) object;
881                return Objects.equal(mLookupUri, otherMember.getLookupUri());
882            }
883            return false;
884        }
885
886        @Override
887        public int hashCode() {
888            return mLookupUri == null ? 0 : mLookupUri.hashCode();
889        }
890
891        // Parcelable
892        @Override
893        public int describeContents() {
894            return 0;
895        }
896
897        @Override
898        public void writeToParcel(Parcel dest, int flags) {
899            dest.writeLong(mRawContactId);
900            dest.writeLong(mContactId);
901            dest.writeParcelable(mLookupUri, flags);
902            dest.writeString(mDisplayName);
903            dest.writeParcelable(mPhotoUri, flags);
904        }
905
906        private Member(Parcel in) {
907            mRawContactId = in.readLong();
908            mContactId = in.readLong();
909            mLookupUri = in.readParcelable(getClass().getClassLoader());
910            mDisplayName = in.readString();
911            mPhotoUri = in.readParcelable(getClass().getClassLoader());
912        }
913
914        public static final Parcelable.Creator<Member> CREATOR = new Parcelable.Creator<Member>() {
915            @Override
916            public Member createFromParcel(Parcel in) {
917                return new Member(in);
918            }
919
920            @Override
921            public Member[] newArray(int size) {
922                return new Member[size];
923            }
924        };
925    }
926
927    /**
928     * This adapter displays a list of members for the current group being edited.
929     */
930    private final class MemberListAdapter extends BaseAdapter {
931
932        private boolean mIsGroupMembershipEditable = true;
933
934        @Override
935        public View getView(int position, View convertView, ViewGroup parent) {
936            View result;
937            if (convertView == null) {
938                result = mLayoutInflater.inflate(mIsGroupMembershipEditable ?
939                        R.layout.group_member_item : R.layout.external_group_member_item,
940                        parent, false);
941            } else {
942                result = convertView;
943            }
944            final Member member = getItem(position);
945
946            QuickContactBadge badge = (QuickContactBadge) result.findViewById(R.id.badge);
947            badge.assignContactUri(member.getLookupUri());
948
949            TextView name = (TextView) result.findViewById(R.id.name);
950            name.setText(member.getDisplayName());
951
952            View deleteButton = result.findViewById(R.id.delete_button_container);
953            if (deleteButton != null) {
954                deleteButton.setOnClickListener(new OnClickListener() {
955                    @Override
956                    public void onClick(View v) {
957                        removeMember(member);
958                    }
959                });
960            }
961
962            mPhotoManager.loadPhoto(badge, member.getPhotoUri(), false, false);
963            return result;
964        }
965
966        @Override
967        public int getCount() {
968            return mListToDisplay.size();
969        }
970
971        @Override
972        public Member getItem(int position) {
973            return mListToDisplay.get(position);
974        }
975
976        @Override
977        public long getItemId(int position) {
978            return position;
979        }
980
981        public void setIsGroupMembershipEditable(boolean editable) {
982            mIsGroupMembershipEditable = editable;
983        }
984    }
985}
986