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