ContactEditorFragment.java revision 51e8d634f2d16aeaed73b6fba42d02a1c2d48b20
1/*
2 * Copyright (C) 2010 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.editor;
18
19import com.android.contacts.ContactLoader;
20import com.android.contacts.ContactSaveService;
21import com.android.contacts.GroupMetaDataLoader;
22import com.android.contacts.R;
23import com.android.contacts.activities.ContactEditorActivity;
24import com.android.contacts.activities.JoinContactActivity;
25import com.android.contacts.editor.AggregationSuggestionEngine.Suggestion;
26import com.android.contacts.editor.Editor.EditorListener;
27import com.android.contacts.model.AccountType;
28import com.android.contacts.model.AccountTypeManager;
29import com.android.contacts.model.EntityDelta;
30import com.android.contacts.model.EntityDelta.ValuesDelta;
31import com.android.contacts.model.EntityDeltaList;
32import com.android.contacts.model.EntityModifier;
33import com.android.contacts.model.GoogleAccountType;
34import com.android.contacts.util.AccountsListAdapter;
35
36import android.accounts.Account;
37import android.app.Activity;
38import android.app.AlertDialog;
39import android.app.Dialog;
40import android.app.DialogFragment;
41import android.app.Fragment;
42import android.app.LoaderManager;
43import android.app.LoaderManager.LoaderCallbacks;
44import android.content.ActivityNotFoundException;
45import android.content.ContentUris;
46import android.content.ContentValues;
47import android.content.Context;
48import android.content.CursorLoader;
49import android.content.DialogInterface;
50import android.content.Entity;
51import android.content.Intent;
52import android.content.Loader;
53import android.database.Cursor;
54import android.graphics.Bitmap;
55import android.graphics.Rect;
56import android.media.MediaScannerConnection;
57import android.net.Uri;
58import android.os.Bundle;
59import android.os.Environment;
60import android.os.SystemClock;
61import android.provider.ContactsContract.CommonDataKinds.Email;
62import android.provider.ContactsContract.CommonDataKinds.Event;
63import android.provider.ContactsContract.CommonDataKinds.Note;
64import android.provider.ContactsContract.CommonDataKinds.Organization;
65import android.provider.ContactsContract.CommonDataKinds.Phone;
66import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
67import android.provider.ContactsContract.CommonDataKinds.Website;
68import android.provider.ContactsContract.Contacts;
69import android.provider.ContactsContract.Intents;
70import android.provider.ContactsContract.RawContacts;
71import android.provider.MediaStore;
72import android.util.Log;
73import android.view.LayoutInflater;
74import android.view.Menu;
75import android.view.MenuInflater;
76import android.view.MenuItem;
77import android.view.View;
78import android.view.ViewGroup;
79import android.view.ViewGroup.LayoutParams;
80import android.view.ViewGroup.MarginLayoutParams;
81import android.view.ViewStub;
82import android.widget.AdapterView;
83import android.widget.LinearLayout;
84import android.widget.ListPopupWindow;
85import android.widget.Toast;
86
87import java.io.File;
88import java.text.SimpleDateFormat;
89import java.util.ArrayList;
90import java.util.Collections;
91import java.util.Comparator;
92import java.util.Date;
93import java.util.List;
94
95public class ContactEditorFragment extends Fragment implements
96        SplitContactConfirmationDialogFragment.Listener,
97        AggregationSuggestionEngine.Listener, AggregationSuggestionView.Listener,
98        ExternalRawContactEditorView.Listener {
99
100    private static final String TAG = "ContactEditorFragment";
101
102    private static final int LOADER_DATA = 1;
103    private static final int LOADER_GROUPS = 2;
104
105    private static final String KEY_URI = "uri";
106    private static final String KEY_ACTION = "action";
107    private static final String KEY_EDIT_STATE = "state";
108    private static final String KEY_RAW_CONTACT_ID_REQUESTING_PHOTO = "photorequester";
109    private static final String KEY_VIEW_ID_GENERATOR = "viewidgenerator";
110    private static final String KEY_CURRENT_PHOTO_FILE = "currentphotofile";
111    private static final String KEY_CONTACT_ID_FOR_JOIN = "contactidforjoin";
112    private static final String KEY_SHOW_JOIN_SUGGESTIONS = "showJoinSuggestions";
113    private static final String KEY_ENABLED = "enabled";
114    private static final String KEY_STATUS = "status";
115
116    public static final String SAVE_MODE_EXTRA_KEY = "saveMode";
117
118    /**
119     * An intent extra that forces the editor to add the edited contact
120     * to the default group (e.g. "My Contacts").
121     */
122    public static final String INTENT_EXTRA_ADD_TO_DEFAULT_DIRECTORY = "addToDefaultDirectory";
123
124    /**
125     * Modes that specify what the AsyncTask has to perform after saving
126     */
127    public interface SaveMode {
128        /**
129         * Close the editor after saving
130         */
131        public static final int CLOSE = 0;
132
133        /**
134         * Reload the data so that the user can continue editing
135         */
136        public static final int RELOAD = 1;
137
138        /**
139         * Split the contact after saving
140         */
141        public static final int SPLIT = 2;
142
143        /**
144         * Join another contact after saving
145         */
146        public static final int JOIN = 3;
147
148        /**
149         * Navigate to Contacts Home activity after saving.
150         */
151        public static final int HOME = 4;
152    }
153
154    private interface Status {
155        /**
156         * The loader is fetching data
157         */
158        public static final int LOADING = 0;
159
160        /**
161         * Not currently busy. We are waiting for the user to enter data
162         */
163        public static final int EDITING = 1;
164
165        /**
166         * The data is currently being saved. This is used to prevent more
167         * auto-saves (they shouldn't overlap)
168         */
169        public static final int SAVING = 2;
170
171        /**
172         * Prevents any more saves. This is used if in the following cases:
173         * - After Save/Close
174         * - After Revert
175         * - After the user has accepted an edit suggestion
176         */
177        public static final int CLOSING = 3;
178
179        /**
180         * Prevents saving while running a child activity.
181         */
182        public static final int SUB_ACTIVITY = 4;
183    }
184
185    private static final int REQUEST_CODE_JOIN = 0;
186    private static final int REQUEST_CODE_CAMERA_WITH_DATA = 1;
187    private static final int REQUEST_CODE_PHOTO_PICKED_WITH_DATA = 2;
188
189    private Bitmap mPhoto = null;
190    private long mRawContactIdRequestingPhoto = -1;
191    private long mRawContactIdRequestingPhotoAfterLoad = -1;
192
193    private final EntityDeltaComparator mComparator = new EntityDeltaComparator();
194
195    private static final int ICON_SIZE = 96;
196
197    private static final File PHOTO_DIR = new File(
198            Environment.getExternalStorageDirectory() + "/DCIM/Camera");
199
200    private Cursor mGroupMetaData;
201
202    /**
203     * A delay in milliseconds used for bringing aggregation suggestions to
204     * the visible part of the screen. The reason this has to be done after
205     * a delay is a race condition with the soft keyboard.  The keyboard
206     * may expand to display its own autocomplete suggestions, which will
207     * reduce the visible area of the screen.  We will yield to the keyboard
208     * hoping that the delay is sufficient.  If not - part of the
209     * suggestion will be hidden, which is not fatal.
210     */
211    private static final int AGGREGATION_SUGGESTION_SCROLL_DELAY = 200;
212
213    private File mCurrentPhotoFile;
214
215    private Context mContext;
216    private String mAction;
217    private Uri mLookupUri;
218    private Bundle mIntentExtras;
219    private Listener mListener;
220
221    private long mContactIdForJoin;
222
223    private LinearLayout mContent;
224    private EntityDeltaList mState;
225
226    private ViewIdGenerator mViewIdGenerator;
227
228    private long mLoaderStartTime;
229
230    private int mStatus;
231
232    private AggregationSuggestionEngine mAggregationSuggestionEngine;
233    private long mAggregationSuggestionsRawContactId;
234    private View mAggregationSuggestionView;
235
236    private boolean mAutoAddToDefaultGroup;
237
238    private boolean mEnabled = true;
239    private boolean mRequestFocus;
240
241    public ContactEditorFragment() {
242    }
243
244    public void setEnabled(boolean enabled) {
245        if (mEnabled != enabled) {
246            mEnabled = enabled;
247            if (mContent != null) {
248                int count = mContent.getChildCount();
249                for (int i = 0; i < count; i++) {
250                    mContent.getChildAt(i).setEnabled(enabled);
251                }
252            }
253            setAggregationSuggestionViewEnabled(enabled);
254            final Activity activity = getActivity();
255            if (activity != null) activity.invalidateOptionsMenu();
256        }
257    }
258
259    @Override
260    public void onAttach(Activity activity) {
261        super.onAttach(activity);
262        mContext = activity;
263    }
264
265    @Override
266    public void onStop() {
267        super.onStop();
268        if (mAggregationSuggestionEngine != null) {
269            mAggregationSuggestionEngine.quit();
270        }
271
272        // If anything was left unsaved, save it now but keep the editor open.
273        if (!getActivity().isChangingConfigurations() && mStatus == Status.EDITING) {
274            save(SaveMode.RELOAD);
275        }
276    }
277
278    @Override
279    public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedState) {
280        final View view = inflater.inflate(R.layout.contact_editor_fragment, container, false);
281
282        mContent = (LinearLayout) view.findViewById(R.id.editors);
283
284        setHasOptionsMenu(true);
285
286        // If we are in an orientation change, we already have mState (it was loaded by onCreate)
287        if (mState != null) {
288            bindEditors();
289        }
290
291        return view;
292    }
293
294    @Override
295    public void onActivityCreated(Bundle savedInstanceState) {
296        super.onActivityCreated(savedInstanceState);
297
298        Log.d(TAG, "onActivityCreated(" + savedInstanceState + ")");
299
300        // Handle initial actions only when existing state missing
301        final boolean hasIncomingState = savedInstanceState != null;
302
303        if (!hasIncomingState) {
304            if (Intent.ACTION_EDIT.equals(mAction)) {
305                if (mListener != null) mListener.setTitleTo(R.string.editContact_title_edit);
306                getLoaderManager().initLoader(LOADER_DATA, null, mDataLoaderListener);
307            } else if (Intent.ACTION_INSERT.equals(mAction)) {
308                if (mListener != null) mListener.setTitleTo(R.string.editContact_title_insert);
309
310                final Account account = mIntentExtras == null ? null :
311                    (Account) mIntentExtras.getParcelable(Intents.Insert.ACCOUNT);
312
313                if (account != null) {
314                    // Account specified in Intent
315                    createContact(account);
316                } else {
317                    // No Account specified. Let the user choose
318                    // Load Accounts async so that we can present them
319                    createContact();
320                }
321            } else if (ContactEditorActivity.ACTION_SAVE_COMPLETED.equals(mAction)) {
322                // do nothing
323            } else throw new IllegalArgumentException("Unknown Action String " + mAction +
324                    ". Only support " + Intent.ACTION_EDIT + " or " + Intent.ACTION_INSERT);
325        }
326    }
327
328    @Override
329    public void onStart() {
330        getLoaderManager().initLoader(LOADER_GROUPS, null, mGroupLoaderListener);
331        super.onStart();
332    }
333
334    public void load(String action, Uri lookupUri, Bundle intentExtras) {
335        mAction = action;
336        mLookupUri = lookupUri;
337        mIntentExtras = intentExtras;
338        mAutoAddToDefaultGroup = mIntentExtras != null
339                && mIntentExtras.containsKey(INTENT_EXTRA_ADD_TO_DEFAULT_DIRECTORY);
340    }
341
342    public void setListener(Listener value) {
343        mListener = value;
344    }
345
346    @Override
347    public void onCreate(Bundle savedState) {
348        if (savedState != null) {
349            // Restore mUri before calling super.onCreate so that onInitializeLoaders
350            // would already have a uri and an action to work with
351            mLookupUri = savedState.getParcelable(KEY_URI);
352            mAction = savedState.getString(KEY_ACTION);
353        }
354
355        super.onCreate(savedState);
356
357        if (savedState == null) {
358            // If savedState is non-null, onRestoreInstanceState() will restore the generator.
359            mViewIdGenerator = new ViewIdGenerator();
360        } else {
361            // Read state from savedState. No loading involved here
362            mState = savedState.<EntityDeltaList> getParcelable(KEY_EDIT_STATE);
363            mRawContactIdRequestingPhoto = savedState.getLong(
364                    KEY_RAW_CONTACT_ID_REQUESTING_PHOTO);
365            mViewIdGenerator = savedState.getParcelable(KEY_VIEW_ID_GENERATOR);
366            String fileName = savedState.getString(KEY_CURRENT_PHOTO_FILE);
367            if (fileName != null) {
368                mCurrentPhotoFile = new File(fileName);
369            }
370            mContactIdForJoin = savedState.getLong(KEY_CONTACT_ID_FOR_JOIN);
371            mAggregationSuggestionsRawContactId = savedState.getLong(KEY_SHOW_JOIN_SUGGESTIONS);
372            mEnabled = savedState.getBoolean(KEY_ENABLED);
373            mStatus = savedState.getInt(KEY_STATUS);
374        }
375    }
376
377    public void setData(ContactLoader.Result data) {
378        // If we have already loaded data, we do not want to change it here to not confuse the user
379        if (mState != null) {
380            Log.v(TAG, "Ignoring background change. This will have to be rebased later");
381            return;
382        }
383
384        // See if this edit operation needs to be redirected to a custom editor
385        ArrayList<Entity> entities = data.getEntities();
386        if (entities.size() == 1) {
387            Entity entity = entities.get(0);
388            ContentValues entityValues = entity.getEntityValues();
389            String type = entityValues.getAsString(RawContacts.ACCOUNT_TYPE);
390            AccountType accountType = AccountTypeManager.getInstance(mContext).getAccountType(type);
391            if (accountType.getEditContactActivityClassName() != null) {
392                if (mListener != null) {
393                    String name = entityValues.getAsString(RawContacts.ACCOUNT_NAME);
394                    long rawContactId = entityValues.getAsLong(RawContacts.Entity._ID);
395                    mListener.onCustomEditContactActivityRequested(new Account(name, type),
396                            ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
397                            mIntentExtras, true);
398                }
399                return;
400            }
401        }
402
403        bindEditorsForExistingContact(data);
404    }
405
406    @Override
407    public void onExternalEditorRequest(Account account, Uri uri) {
408        mListener.onCustomEditContactActivityRequested(account, uri, null, false);
409    }
410
411    private void bindEditorsForExistingContact(ContactLoader.Result data) {
412        setEnabled(true);
413
414        mState = EntityDeltaList.fromIterator(data.getEntities().iterator());
415        setIntentExtras(mIntentExtras);
416        mIntentExtras = null;
417
418        mRequestFocus = true;
419
420        bindEditors();
421    }
422
423    /**
424     * Merges extras from the intent.
425     */
426    public void setIntentExtras(Bundle extras) {
427        if (extras == null || extras.size() == 0) {
428            return;
429        }
430
431        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
432        for (EntityDelta state : mState) {
433            final String accountType = state.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
434            final AccountType type = accountTypes.getAccountType(accountType);
435            if (!type.readOnly) {
436                // Apply extras to the first writable raw contact only
437                EntityModifier.parseExtras(mContext, type, state, extras);
438                break;
439            }
440        }
441    }
442
443    /**
444     * Shows the account creation screen. An account associated with the contact is automatically
445     * selected. If there's no available account, device-local contact should be created.
446     */
447    private void createContact() {
448        final ArrayList<Account> accounts =
449                AccountTypeManager.getInstance(mContext).getAccounts(true);
450        // No Accounts available.  Create a phone-local contact.
451        if (accounts.isEmpty()) {
452            createContact(null);
453            return;  // Don't show a dialog.
454        }
455
456        // We have an account switcher in "create-account" screen, so don't need to ask a user to
457        // select an account here.
458        createContact(accounts.get(0));
459    }
460
461
462    /**
463     * Shows account creation screen associated with a given account.
464     *
465     * @param account may be null to signal a device-local contact should be created.
466     */
467    private void createContact(Account account) {
468        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
469        final AccountType accountType =
470                accountTypes.getAccountType(account != null ? account.type : null);
471
472        if (accountType.getCreateContactActivityClassName() != null) {
473            if (mListener != null) {
474                mListener.onCustomCreateContactActivityRequested(account, mIntentExtras);
475            }
476        } else {
477            bindEditorsForNewContact(account, accountType);
478        }
479    }
480
481    /**
482     * Removes a current editor ({@link #mState}) and rebinds new editor for a new account.
483     * Some of old data are reused with new restriction enforced by the new account.
484     *
485     * @param oldState Old data being editted.
486     * @param oldAccount Old account associated with oldState.
487     * @param newAccount New account to be used.
488     */
489    private void rebindEditorsForNewContact(
490            EntityDelta oldState, Account oldAccount, Account newAccount) {
491        AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
492        AccountType oldAccountType = accountTypes.getAccountType(oldAccount.type);
493        AccountType newAccountType = accountTypes.getAccountType(newAccount.type);
494
495        if (newAccountType.getCreateContactActivityClassName() != null) {
496            Log.w(TAG, "external activity called in rebind situation");
497            if (mListener != null) {
498                mListener.onCustomCreateContactActivityRequested(newAccount, mIntentExtras);
499            }
500        } else {
501            mState = null;
502            bindEditorsForNewContact(newAccount, newAccountType, oldState, oldAccountType);
503        }
504    }
505
506    private void bindEditorsForNewContact(Account account, final AccountType accountType) {
507        bindEditorsForNewContact(account, accountType, null, null);
508    }
509
510    private void bindEditorsForNewContact(Account newAccount, final AccountType newAccountType,
511            EntityDelta oldState, AccountType oldAccountType) {
512        mStatus = Status.EDITING;
513
514        final ContentValues values = new ContentValues();
515        if (newAccount != null) {
516            values.put(RawContacts.ACCOUNT_NAME, newAccount.name);
517            values.put(RawContacts.ACCOUNT_TYPE, newAccount.type);
518        } else {
519            values.putNull(RawContacts.ACCOUNT_NAME);
520            values.putNull(RawContacts.ACCOUNT_TYPE);
521        }
522
523        EntityDelta insert = new EntityDelta(ValuesDelta.fromAfter(values));
524        if (oldState == null) {
525            // Parse any values from incoming intent
526            EntityModifier.parseExtras(mContext, newAccountType, insert, mIntentExtras);
527        } else {
528            EntityModifier.migrateStateForNewContact(mContext, oldState, insert,
529                    oldAccountType, newAccountType);
530        }
531
532        // Ensure we have some default fields (if the account type does not support a field,
533        // ensureKind will not add it, so it is safe to add e.g. Event)
534        EntityModifier.ensureKindExists(insert, newAccountType, Phone.CONTENT_ITEM_TYPE);
535        EntityModifier.ensureKindExists(insert, newAccountType, Email.CONTENT_ITEM_TYPE);
536        EntityModifier.ensureKindExists(insert, newAccountType, Note.CONTENT_ITEM_TYPE);
537        EntityModifier.ensureKindExists(insert, newAccountType, Organization.CONTENT_ITEM_TYPE);
538        EntityModifier.ensureKindExists(insert, newAccountType, Event.CONTENT_ITEM_TYPE);
539        EntityModifier.ensureKindExists(insert, newAccountType, Website.CONTENT_ITEM_TYPE);
540        EntityModifier.ensureKindExists(insert, newAccountType, StructuredPostal.CONTENT_ITEM_TYPE);
541
542        if (mState == null) {
543            // Create state if none exists yet
544            mState = EntityDeltaList.fromSingle(insert);
545        } else {
546            // Add contact onto end of existing state
547            mState.add(insert);
548        }
549
550        mRequestFocus = true;
551
552        bindEditors();
553    }
554
555    private void bindEditors() {
556        // Sort the editors
557        Collections.sort(mState, mComparator);
558
559        // Remove any existing editors and rebuild any visible
560        mContent.removeAllViews();
561
562        final LayoutInflater inflater = (LayoutInflater) mContext.getSystemService(
563                Context.LAYOUT_INFLATER_SERVICE);
564        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
565        int size = mState.size();
566        for (int i = 0; i < size; i++) {
567            // TODO ensure proper ordering of entities in the list
568            final EntityDelta entity = mState.get(i);
569            final ValuesDelta values = entity.getValues();
570            if (!values.isVisible()) continue;
571
572            final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE);
573            final AccountType type = accountTypes.getAccountType(accountType);
574            final long rawContactId = values.getAsLong(RawContacts._ID);
575
576            final BaseRawContactEditorView editor;
577            if (type.isExternal()) {
578                editor = (BaseRawContactEditorView) inflater.inflate(
579                        R.layout.external_raw_contact_editor_view, mContent, false);
580                ((ExternalRawContactEditorView) editor).setListener(this);
581            } else {
582                editor = (BaseRawContactEditorView)
583                        inflater.inflate(R.layout.raw_contact_editor_view, mContent, false);
584            }
585            if (Intent.ACTION_INSERT.equals(mAction) && size == 1) {
586                final ArrayList<Account> accounts =
587                        AccountTypeManager.getInstance(mContext).getAccounts(true);
588                if (accounts.size() > 1) {
589                    addAccountSwitcher(mState.get(0), editor);
590                }
591            }
592            editor.setEnabled(mEnabled);
593
594            mContent.addView(editor);
595
596            editor.setState(entity, type, mViewIdGenerator);
597
598            editor.getPhotoEditor().setEditorListener(
599                    new PhotoEditorListener(editor, type.readOnly));
600            if (editor instanceof RawContactEditorView) {
601                final RawContactEditorView rawContactEditor = (RawContactEditorView) editor;
602                EditorListener listener = new EditorListener() {
603
604                    @Override
605                    public void onRequest(int request) {
606                        if (request == EditorListener.FIELD_CHANGED) {
607                            acquireAggregationSuggestions(rawContactEditor);
608                        }
609                    }
610
611                    @Override
612                    public void onDeleted(Editor removedEditor) {
613                    }
614                };
615
616                final TextFieldsEditorView nameEditor = rawContactEditor.getNameEditor();
617                if (mRequestFocus) {
618                    nameEditor.requestFocus();
619                    mRequestFocus = false;
620                }
621                nameEditor.setEditorListener(listener);
622
623                final TextFieldsEditorView phoneticNameEditor =
624                        rawContactEditor.getPhoneticNameEditor();
625                phoneticNameEditor.setEditorListener(listener);
626                rawContactEditor.setAutoAddToDefaultGroup(mAutoAddToDefaultGroup);
627
628                if (rawContactId == mAggregationSuggestionsRawContactId) {
629                    acquireAggregationSuggestions(rawContactEditor);
630                }
631            }
632        }
633
634        mRequestFocus = false;
635
636        bindGroupMetaData();
637
638        // Show editor now that we've loaded state
639        mContent.setVisibility(View.VISIBLE);
640
641        // Refresh Action Bar as the visibility of the join command
642        // Activity can be null if we have been detached from the Activity
643        final Activity activity = getActivity();
644        if (activity != null) activity.invalidateOptionsMenu();
645
646    }
647
648    private void bindGroupMetaData() {
649        if (mGroupMetaData == null) {
650            return;
651        }
652
653        int editorCount = mContent.getChildCount();
654        for (int i = 0; i < editorCount; i++) {
655            BaseRawContactEditorView editor = (BaseRawContactEditorView) mContent.getChildAt(i);
656            editor.setGroupMetaData(mGroupMetaData);
657        }
658    }
659
660    private void addAccountSwitcher(
661            final EntityDelta currentState, BaseRawContactEditorView editor) {
662        ValuesDelta values = currentState.getValues();
663        final Account currentAccount = new Account(
664                values.getAsString(RawContacts.ACCOUNT_NAME),
665                values.getAsString(RawContacts.ACCOUNT_TYPE));
666        final View accountView = editor.findViewById(R.id.account);
667        final View anchorView = editor.findViewById(R.id.anchor_for_account_switcher);
668        accountView.setOnClickListener(new View.OnClickListener() {
669            @Override
670            public void onClick(View v) {
671                final ListPopupWindow popup = new ListPopupWindow(mContext, null);
672                final AccountsListAdapter adapter = new AccountsListAdapter(mContext, true);
673                popup.setWidth(anchorView.getWidth());
674                popup.setAnchorView(anchorView);
675                popup.setAdapter(adapter);
676                popup.setModal(true);
677                popup.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
678                popup.setOnItemClickListener(new AdapterView.OnItemClickListener() {
679                    @Override
680                    public void onItemClick(AdapterView<?> parent, View view, int position,
681                            long id) {
682                        popup.dismiss();
683                        Account newAccount = adapter.getItem(position);
684                        if (!newAccount.equals(currentAccount)) {
685                            rebindEditorsForNewContact(currentState, currentAccount, newAccount);
686                        }
687                    }
688                });
689                popup.show();
690            }
691        });
692    }
693
694    @Override
695    public void onCreateOptionsMenu(Menu menu, final MenuInflater inflater) {
696        inflater.inflate(R.menu.edit, menu);
697    }
698
699    @Override
700    public void onPrepareOptionsMenu(Menu menu) {
701        menu.findItem(R.id.menu_split).setVisible(mState != null && mState.size() > 1);
702        int size = menu.size();
703        for (int i = 0; i < size; i++) {
704            menu.getItem(i).setEnabled(mEnabled);
705        }
706    }
707
708    @Override
709    public boolean onOptionsItemSelected(MenuItem item) {
710        switch (item.getItemId()) {
711            case R.id.menu_done:
712                return save(SaveMode.CLOSE);
713            case R.id.menu_discard:
714                return revert();
715            case R.id.menu_delete:
716                return doDeleteAction();
717            case R.id.menu_split:
718                return doSplitContactAction();
719            case R.id.menu_join:
720                return doJoinContactAction();
721        }
722        return false;
723    }
724
725    /**
726     * Delete the entire contact currently being edited, which usually asks for
727     * user confirmation before continuing.
728     */
729    private boolean doDeleteAction() {
730        if (!hasValidState())
731            return false;
732
733        // TODO: Make sure Insert turns into Edit if/once it is autosaved
734        if (Intent.ACTION_INSERT.equals(mAction)) {
735            if (mListener != null) mListener.onReverted();
736        } else {
737            if (mListener != null) mListener.onDeleteRequested(mLookupUri);
738        }
739        return true;
740    }
741
742    private boolean doSplitContactAction() {
743        if (!hasValidState()) return false;
744
745        final SplitContactConfirmationDialogFragment dialog =
746                new SplitContactConfirmationDialogFragment();
747        dialog.setTargetFragment(this, 0);
748        dialog.show(getFragmentManager(), SplitContactConfirmationDialogFragment.TAG);
749        return true;
750    }
751
752    private boolean doJoinContactAction() {
753        if (!hasValidState()) {
754            return false;
755        }
756
757        // If we just started creating a new contact and haven't added any data, it's too
758        // early to do a join
759        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
760        if (mState.size() == 1 && mState.get(0).isContactInsert()
761                && !EntityModifier.hasChanges(mState, accountTypes)) {
762            Toast.makeText(getActivity(), R.string.toast_join_with_empty_contact,
763                            Toast.LENGTH_LONG).show();
764            return true;
765        }
766
767        return save(SaveMode.JOIN);
768    }
769
770    /**
771     * Constructs an intent for picking a photo from Gallery, cropping it and returning the bitmap.
772     */
773    public static Intent getPhotoPickIntent() {
774        Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null);
775        intent.setType("image/*");
776        intent.putExtra("crop", "true");
777        intent.putExtra("aspectX", 1);
778        intent.putExtra("aspectY", 1);
779        intent.putExtra("outputX", ICON_SIZE);
780        intent.putExtra("outputY", ICON_SIZE);
781        intent.putExtra("return-data", true);
782        return intent;
783    }
784
785    /**
786     * Check if our internal {@link #mState} is valid, usually checked before
787     * performing user actions.
788     */
789    private boolean hasValidState() {
790        return mState != null && mState.size() > 0;
791    }
792
793    /**
794     * Create a file name for the icon photo using current time.
795     */
796    private String getPhotoFileName() {
797        Date date = new Date(System.currentTimeMillis());
798        SimpleDateFormat dateFormat = new SimpleDateFormat("'IMG'_yyyyMMdd_HHmmss");
799        return dateFormat.format(date) + ".jpg";
800    }
801
802    /**
803     * Constructs an intent for capturing a photo and storing it in a temporary file.
804     */
805    public static Intent getTakePickIntent(File f) {
806        Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE, null);
807        intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(f));
808        return intent;
809    }
810
811    /**
812     * Sends a newly acquired photo to Gallery for cropping
813     */
814    protected void doCropPhoto(File f) {
815        try {
816            // Add the image to the media store
817            MediaScannerConnection.scanFile(
818                    mContext,
819                    new String[] { f.getAbsolutePath() },
820                    new String[] { null },
821                    null);
822
823            // Launch gallery to crop the photo
824            final Intent intent = getCropImageIntent(Uri.fromFile(f));
825            mStatus = Status.SUB_ACTIVITY;
826            startActivityForResult(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
827        } catch (Exception e) {
828            Log.e(TAG, "Cannot crop image", e);
829            Toast.makeText(mContext, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
830        }
831    }
832
833    /**
834     * Constructs an intent for image cropping.
835     */
836    public static Intent getCropImageIntent(Uri photoUri) {
837        Intent intent = new Intent("com.android.camera.action.CROP");
838        intent.setDataAndType(photoUri, "image/*");
839        intent.putExtra("crop", "true");
840        intent.putExtra("aspectX", 1);
841        intent.putExtra("aspectY", 1);
842        intent.putExtra("outputX", ICON_SIZE);
843        intent.putExtra("outputY", ICON_SIZE);
844        intent.putExtra("return-data", true);
845        return intent;
846    }
847
848    /**
849     * Saves or creates the contact based on the mode, and if successful
850     * finishes the activity.
851     */
852    public boolean save(int saveMode) {
853        if (!hasValidState() || mStatus != Status.EDITING) {
854            return false;
855        }
856
857        // If we are about to close the editor - there is no need to refresh the data
858        if (saveMode == SaveMode.CLOSE || saveMode == SaveMode.SPLIT) {
859            getLoaderManager().destroyLoader(LOADER_DATA);
860        }
861
862        mStatus = Status.SAVING;
863
864        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
865        if (!EntityModifier.hasChanges(mState, accountTypes)) {
866            onSaveCompleted(false, saveMode, mLookupUri);
867            return true;
868        }
869
870        setEnabled(false);
871
872        Intent intent = ContactSaveService.createSaveContactIntent(getActivity(), mState,
873                SAVE_MODE_EXTRA_KEY, saveMode, getActivity().getClass(),
874                ContactEditorActivity.ACTION_SAVE_COMPLETED);
875        getActivity().startService(intent);
876        return true;
877    }
878
879    public static class CancelEditDialogFragment extends DialogFragment {
880
881        public static void show(ContactEditorFragment fragment) {
882            CancelEditDialogFragment dialog = new CancelEditDialogFragment();
883            dialog.setTargetFragment(fragment, 0);
884            dialog.show(fragment.getFragmentManager(), "cancelEditor");
885        }
886
887        @Override
888        public Dialog onCreateDialog(Bundle savedInstanceState) {
889            AlertDialog dialog = new AlertDialog.Builder(getActivity())
890                    .setIconAttribute(android.R.attr.alertDialogIcon)
891                    .setTitle(R.string.cancel_confirmation_dialog_title)
892                    .setMessage(R.string.cancel_confirmation_dialog_message)
893                    .setPositiveButton(R.string.discard,
894                        new DialogInterface.OnClickListener() {
895                            @Override
896                            public void onClick(DialogInterface dialog, int whichButton) {
897                                ((ContactEditorFragment)getTargetFragment()).doRevertAction();
898                            }
899                        }
900                    )
901                    .setNegativeButton(android.R.string.cancel, null)
902                    .create();
903            return dialog;
904        }
905    }
906
907    private boolean revert() {
908        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
909        if (mState == null || !EntityModifier.hasChanges(mState, accountTypes)) {
910            doRevertAction();
911        } else {
912            CancelEditDialogFragment.show(this);
913        }
914        return true;
915    }
916
917    private void doRevertAction() {
918        // When this Fragment is closed we don't want it to auto-save
919        mStatus = Status.CLOSING;
920        if (mListener != null) mListener.onReverted();
921    }
922
923    public void onJoinCompleted(Uri uri) {
924        onSaveCompleted(false, SaveMode.RELOAD, uri);
925    }
926
927    public void onSaveCompleted(boolean hadChanges, int saveMode, Uri contactLookupUri) {
928        boolean success = contactLookupUri != null;
929        Log.d(TAG, "onSaveCompleted(" + saveMode + ", " + contactLookupUri);
930        if (hadChanges) {
931            if (success) {
932                if (saveMode != SaveMode.JOIN) {
933                    Toast.makeText(mContext, R.string.contactSavedToast, Toast.LENGTH_SHORT).show();
934                }
935            } else {
936                Toast.makeText(mContext, R.string.contactSavedErrorToast, Toast.LENGTH_LONG).show();
937            }
938        }
939        switch (saveMode) {
940            case SaveMode.CLOSE:
941            case SaveMode.HOME:
942                final Intent resultIntent;
943                final int resultCode;
944                if (success && contactLookupUri != null) {
945                    final String requestAuthority =
946                            mLookupUri == null ? null : mLookupUri.getAuthority();
947
948                    final String legacyAuthority = "contacts";
949
950                    resultIntent = new Intent();
951                    if (legacyAuthority.equals(requestAuthority)) {
952                        // Build legacy Uri when requested by caller
953                        final long contactId = ContentUris.parseId(Contacts.lookupContact(
954                                mContext.getContentResolver(), contactLookupUri));
955                        final Uri legacyContentUri = Uri.parse("content://contacts/people");
956                        final Uri legacyUri = ContentUris.withAppendedId(
957                                legacyContentUri, contactId);
958                        resultIntent.setData(legacyUri);
959                    } else {
960                        // Otherwise pass back a lookup-style Uri
961                        resultIntent.setData(contactLookupUri);
962                    }
963
964                    resultCode = Activity.RESULT_OK;
965                } else {
966                    resultCode = Activity.RESULT_CANCELED;
967                    resultIntent = null;
968                }
969                // It is already saved, so prevent that it is saved again
970                mStatus = Status.CLOSING;
971                if (mListener != null) mListener.onSaveFinished(resultCode, resultIntent,
972                        saveMode == SaveMode.HOME);
973                break;
974
975            case SaveMode.RELOAD:
976            case SaveMode.JOIN:
977                if (success && contactLookupUri != null) {
978                    // If this was in INSERT, we are changing into an EDIT now.
979                    // If it already was an EDIT, we are changing to the new Uri now
980                    mState = null;
981                    load(Intent.ACTION_EDIT, contactLookupUri, null);
982                    mStatus = Status.LOADING;
983                    getLoaderManager().restartLoader(LOADER_DATA, null, mDataLoaderListener);
984
985                    // If it was a JOIN, we are now ready to bring up the join activity.
986                    if (saveMode == SaveMode.JOIN) {
987                        showJoinAggregateActivity(contactLookupUri);
988                    }
989                }
990                break;
991
992            case SaveMode.SPLIT:
993                mStatus = Status.CLOSING;
994                if (mListener != null) {
995                    mListener.onContactSplit(contactLookupUri);
996                } else {
997                    Log.d(TAG, "No listener registered, can not call onSplitFinished");
998                }
999                break;
1000        }
1001    }
1002
1003    /**
1004     * Shows a list of aggregates that can be joined into the currently viewed aggregate.
1005     *
1006     * @param contactLookupUri the fresh URI for the currently edited contact (after saving it)
1007     */
1008    private void showJoinAggregateActivity(Uri contactLookupUri) {
1009        if (contactLookupUri == null || !isAdded()) {
1010            return;
1011        }
1012
1013        mContactIdForJoin = ContentUris.parseId(contactLookupUri);
1014        final Intent intent = new Intent(JoinContactActivity.JOIN_CONTACT);
1015        intent.putExtra(JoinContactActivity.EXTRA_TARGET_CONTACT_ID, mContactIdForJoin);
1016        startActivityForResult(intent, REQUEST_CODE_JOIN);
1017    }
1018
1019    /**
1020     * Performs aggregation with the contact selected by the user from suggestions or A-Z list.
1021     */
1022    private void joinAggregate(final long contactId) {
1023        Intent intent = ContactSaveService.createJoinContactsIntent(mContext, mContactIdForJoin,
1024                contactId, isContactWritable(),
1025                ContactEditorActivity.class, ContactEditorActivity.ACTION_JOIN_COMPLETED);
1026        mContext.startService(intent);
1027    }
1028
1029    /**
1030     * Returns true if there is at least one writable raw contact in the current contact.
1031     */
1032    private boolean isContactWritable() {
1033        final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
1034        int size = mState.size();
1035        for (int i = 0; i < size; i++) {
1036            ValuesDelta values = mState.get(i).getValues();
1037            final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE);
1038            final AccountType type = accountTypes.getAccountType(accountType);
1039            if (!type.readOnly) {
1040                return true;
1041            }
1042        }
1043        return false;
1044    }
1045
1046    public static interface Listener {
1047        /**
1048         * Contact was not found, so somehow close this fragment. This is raised after a contact
1049         * is removed via Menu/Delete (unless it was a new contact)
1050         */
1051        void onContactNotFound();
1052
1053        /**
1054         * Contact was split, so we can close now.
1055         * @param newLookupUri The lookup uri of the new contact that should be shown to the user.
1056         * The editor tries best to chose the most natural contact here.
1057         */
1058        void onContactSplit(Uri newLookupUri);
1059
1060        /**
1061         * User has tapped Revert, close the fragment now.
1062         */
1063        void onReverted();
1064
1065        /**
1066         * Set the Title (e.g. of the Activity)
1067         */
1068        void setTitleTo(int resourceId);
1069
1070        /**
1071         * Contact was saved and the Fragment can now be closed safely.
1072         */
1073        void onSaveFinished(int resultCode, Intent resultIntent, boolean navigateHome);
1074
1075        /**
1076         * User decided to delete the contact.
1077         */
1078        void onDeleteRequested(Uri lookupUri);
1079
1080        /**
1081         * User switched to editing a different contact (a suggestion from the
1082         * aggregation engine).
1083         */
1084        void onEditOtherContactRequested(
1085                Uri contactLookupUri, ArrayList<ContentValues> contentValues);
1086
1087        /**
1088         * Contact is being created for an external account that provides its own
1089         * new contact activity.
1090         */
1091        void onCustomCreateContactActivityRequested(Account account, Bundle intentExtras);
1092
1093        /**
1094         * The edited raw contact belongs to an external account that provides
1095         * its own edit activity.
1096         *
1097         * @param redirect indicates that the current editor should be closed
1098         *            before the custom editor is shown.
1099         */
1100        void onCustomEditContactActivityRequested(Account account, Uri rawContactUri,
1101                Bundle intentExtras, boolean redirect);
1102    }
1103
1104    private class EntityDeltaComparator implements Comparator<EntityDelta> {
1105        /**
1106         * Compare EntityDeltas for sorting the stack of editors.
1107         */
1108        @Override
1109        public int compare(EntityDelta one, EntityDelta two) {
1110            // Check direct equality
1111            if (one.equals(two)) {
1112                return 0;
1113            }
1114
1115            final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
1116            String accountType2 = one.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
1117            final AccountType type1 = accountTypes.getAccountType(accountType2);
1118            accountType2 = two.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
1119            final AccountType type2 = accountTypes.getAccountType(accountType2);
1120
1121            // Check read-only
1122            if (type1.readOnly && !type2.readOnly) {
1123                return 1;
1124            } else if (!type1.readOnly && type2.readOnly) {
1125                return -1;
1126            }
1127
1128            // Check account type
1129            boolean skipAccountTypeCheck = false;
1130            boolean isGoogleAccount1 = type1 instanceof GoogleAccountType;
1131            boolean isGoogleAccount2 = type2 instanceof GoogleAccountType;
1132            if (isGoogleAccount1 && !isGoogleAccount2) {
1133                return -1;
1134            } else if (!isGoogleAccount1 && isGoogleAccount2) {
1135                return 1;
1136            } else if (isGoogleAccount1 && isGoogleAccount2){
1137                skipAccountTypeCheck = true;
1138            }
1139
1140            int value;
1141            if (!skipAccountTypeCheck) {
1142                if (type1.accountType == null) {
1143                    return 1;
1144                }
1145                value = type1.accountType.compareTo(type2.accountType);
1146                if (value != 0) {
1147                    return value;
1148                }
1149            }
1150
1151            // Check account name
1152            ValuesDelta oneValues = one.getValues();
1153            String oneAccount = oneValues.getAsString(RawContacts.ACCOUNT_NAME);
1154            if (oneAccount == null) oneAccount = "";
1155            ValuesDelta twoValues = two.getValues();
1156            String twoAccount = twoValues.getAsString(RawContacts.ACCOUNT_NAME);
1157            if (twoAccount == null) twoAccount = "";
1158            value = oneAccount.compareTo(twoAccount);
1159            if (value != 0) {
1160                return value;
1161            }
1162
1163            // Both are in the same account, fall back to contact ID
1164            Long oneId = oneValues.getAsLong(RawContacts._ID);
1165            Long twoId = twoValues.getAsLong(RawContacts._ID);
1166            if (oneId == null) {
1167                return -1;
1168            } else if (twoId == null) {
1169                return 1;
1170            }
1171
1172            return (int)(oneId - twoId);
1173        }
1174    }
1175
1176    /**
1177     * Returns the contact ID for the currently edited contact or 0 if the contact is new.
1178     */
1179    protected long getContactId() {
1180        if (mState != null) {
1181            for (EntityDelta rawContact : mState) {
1182                Long contactId = rawContact.getValues().getAsLong(RawContacts.CONTACT_ID);
1183                if (contactId != null) {
1184                    return contactId;
1185                }
1186            }
1187        }
1188        return 0;
1189    }
1190
1191    /**
1192     * Triggers an asynchronous search for aggregation suggestions.
1193     */
1194    public void acquireAggregationSuggestions(RawContactEditorView rawContactEditor) {
1195        long rawContactId = rawContactEditor.getRawContactId();
1196        if (mAggregationSuggestionsRawContactId != rawContactId
1197                && mAggregationSuggestionView != null) {
1198            mAggregationSuggestionView.setVisibility(View.GONE);
1199            mAggregationSuggestionView = null;
1200            mAggregationSuggestionEngine.reset();
1201        }
1202
1203        mAggregationSuggestionsRawContactId = rawContactId;
1204
1205        if (mAggregationSuggestionEngine == null) {
1206            mAggregationSuggestionEngine = new AggregationSuggestionEngine(getActivity());
1207            mAggregationSuggestionEngine.setListener(this);
1208            mAggregationSuggestionEngine.start();
1209        }
1210
1211        mAggregationSuggestionEngine.setContactId(getContactId());
1212
1213        LabeledEditorView nameEditor = rawContactEditor.getNameEditor();
1214        mAggregationSuggestionEngine.onNameChange(nameEditor.getValues());
1215    }
1216
1217    @Override
1218    public void onAggregationSuggestionChange() {
1219        if (!isAdded() || mState == null || mStatus != Status.EDITING) {
1220            return;
1221        }
1222
1223        RawContactEditorView rawContactView =
1224                (RawContactEditorView)getRawContactEditorView(mAggregationSuggestionsRawContactId);
1225        if (rawContactView == null) {
1226            return;
1227        }
1228
1229        ViewStub stub = (ViewStub)rawContactView.findViewById(R.id.aggregation_suggestion_stub);
1230        if (stub != null) {
1231            stub.inflate();
1232        }
1233
1234        // Only request the view on screen when it is first displayed
1235        boolean requestOnScreen = mAggregationSuggestionView == null;
1236        mAggregationSuggestionView = rawContactView.findViewById(R.id.aggregation_suggestion);
1237
1238        int count = mAggregationSuggestionEngine.getSuggestedContactCount();
1239        if (count == 0) {
1240            mAggregationSuggestionView.setVisibility(View.GONE);
1241            return;
1242        }
1243
1244        List<Suggestion> suggestions = mAggregationSuggestionEngine.getSuggestions();
1245
1246        LinearLayout itemList = (LinearLayout) mAggregationSuggestionView.findViewById(
1247                R.id.aggregation_suggestions);
1248        itemList.removeAllViews();
1249
1250        LayoutInflater inflater = getActivity().getLayoutInflater();
1251
1252        for (Suggestion suggestion : suggestions) {
1253            AggregationSuggestionView suggestionView =
1254                    (AggregationSuggestionView) inflater.inflate(
1255                            R.layout.aggregation_suggestions_item, null);
1256            suggestionView.setLayoutParams(
1257                    new LinearLayout.LayoutParams(
1258                            LayoutParams.MATCH_PARENT, LayoutParams.WRAP_CONTENT));
1259            suggestionView.setNewContact(mState.size() == 1 && mState.get(0).isContactInsert());
1260            suggestionView.setListener(this);
1261            suggestionView.bindSuggestion(suggestion);
1262            itemList.addView(suggestionView);
1263        }
1264
1265        adjustAggregationSuggestionViewLayout(rawContactView);
1266        setAggregationSuggestionViewEnabled(mEnabled);
1267        mAggregationSuggestionView.setVisibility(View.VISIBLE);
1268
1269        if (requestOnScreen) {
1270            mContent.postDelayed(new Runnable() {
1271
1272                @Override
1273                public void run() {
1274                    requestAggregationSuggestionOnScreen(mAggregationSuggestionView);
1275                }
1276            }, AGGREGATION_SUGGESTION_SCROLL_DELAY);
1277        }
1278    }
1279
1280    /**
1281     * Adjusts the layout of the aggregation suggestion view so that it is placed directly
1282     * underneath and have the same width as the last text editor of the contact name editor.
1283     */
1284    private void adjustAggregationSuggestionViewLayout(RawContactEditorView rawContactView) {
1285        TextFieldsEditorView nameEditor = rawContactView.getNameEditor();
1286        Rect rect = new Rect();
1287        nameEditor.acquireEditorBounds(rect);
1288        MarginLayoutParams layoutParams =
1289                (MarginLayoutParams) mAggregationSuggestionView.getLayoutParams();
1290        layoutParams.leftMargin = rect.left;
1291        layoutParams.width = rect.width();
1292        mAggregationSuggestionView.setLayoutParams(layoutParams);
1293    }
1294
1295    @Override
1296    public void onJoinAction(long contactId, List<Long> rawContactIdList) {
1297        long rawContactIds[] = new long[rawContactIdList.size()];
1298        for (int i = 0; i < rawContactIds.length; i++) {
1299            rawContactIds[i] = rawContactIdList.get(i);
1300        }
1301        JoinSuggestedContactDialogFragment dialog =
1302                new JoinSuggestedContactDialogFragment();
1303        Bundle args = new Bundle();
1304        args.putLongArray("rawContactIds", rawContactIds);
1305        dialog.setArguments(args);
1306        dialog.setTargetFragment(this, 0);
1307        try {
1308            dialog.show(getFragmentManager(), "join");
1309        } catch (Exception ex) {
1310            // No problem - the activity is no longer available to display the dialog
1311        }
1312    }
1313
1314    public static class JoinSuggestedContactDialogFragment extends DialogFragment {
1315
1316        @Override
1317        public Dialog onCreateDialog(Bundle savedInstanceState) {
1318            return new AlertDialog.Builder(getActivity())
1319                    .setIconAttribute(android.R.attr.alertDialogIcon)
1320                    .setTitle(R.string.aggregation_suggestion_join_dialog_title)
1321                    .setMessage(R.string.aggregation_suggestion_join_dialog_message)
1322                    .setPositiveButton(android.R.string.yes,
1323                        new DialogInterface.OnClickListener() {
1324                            public void onClick(DialogInterface dialog, int whichButton) {
1325                                ContactEditorFragment targetFragment =
1326                                        (ContactEditorFragment) getTargetFragment();
1327                                long rawContactIds[] =
1328                                        getArguments().getLongArray("rawContactIds");
1329                                targetFragment.doJoinSuggestedContact(rawContactIds);
1330                            }
1331                        }
1332                    )
1333                    .setNegativeButton(android.R.string.no, null)
1334                    .create();
1335        }
1336    }
1337
1338    /**
1339     * Joins the suggested contact (specified by the id's of constituent raw
1340     * contacts), save all changes, and stay in the editor.
1341     */
1342    protected void doJoinSuggestedContact(long[] rawContactIds) {
1343        if (!hasValidState() || mStatus != Status.EDITING) {
1344            return;
1345        }
1346
1347        mState.setJoinWithRawContacts(rawContactIds);
1348        save(SaveMode.RELOAD);
1349    }
1350
1351    @Override
1352    public void onEditAction(Uri contactLookupUri) {
1353        SuggestionEditConfirmationDialogFragment dialog =
1354                new SuggestionEditConfirmationDialogFragment();
1355        Bundle args = new Bundle();
1356        args.putParcelable("contactUri", contactLookupUri);
1357        dialog.setArguments(args);
1358        dialog.setTargetFragment(this, 0);
1359        dialog.show(getFragmentManager(), "edit");
1360    }
1361
1362    public static class SuggestionEditConfirmationDialogFragment extends DialogFragment {
1363
1364        @Override
1365        public Dialog onCreateDialog(Bundle savedInstanceState) {
1366            return new AlertDialog.Builder(getActivity())
1367                    .setIconAttribute(android.R.attr.alertDialogIcon)
1368                    .setTitle(R.string.aggregation_suggestion_edit_dialog_title)
1369                    .setMessage(R.string.aggregation_suggestion_edit_dialog_message)
1370                    .setPositiveButton(android.R.string.yes,
1371                        new DialogInterface.OnClickListener() {
1372                            public void onClick(DialogInterface dialog, int whichButton) {
1373                                ContactEditorFragment targetFragment =
1374                                        (ContactEditorFragment) getTargetFragment();
1375                                Uri contactUri =
1376                                        getArguments().getParcelable("contactUri");
1377                                targetFragment.doEditSuggestedContact(contactUri);
1378                            }
1379                        }
1380                    )
1381                    .setNegativeButton(android.R.string.no, null)
1382                    .create();
1383        }
1384    }
1385
1386    /**
1387     * Abandons the currently edited contact and switches to editing the suggested
1388     * one, transferring all the data there
1389     */
1390    protected void doEditSuggestedContact(Uri contactUri) {
1391        if (mListener != null) {
1392            // make sure we don't save this contact when closing down
1393            mStatus = Status.CLOSING;
1394            mListener.onEditOtherContactRequested(
1395                    contactUri, mState.get(0).getContentValues());
1396        }
1397    }
1398
1399    /**
1400     * Scrolls the editor if necessary to reveal the aggregation suggestion that is
1401     * shown below the name editor. Makes sure that the currently focused field
1402     * remains visible.
1403     */
1404    private void requestAggregationSuggestionOnScreen(final View view) {
1405        Rect rect = getRelativeBounds(mContent, view);
1406        View focused = mContent.findFocus();
1407        if (focused != null) {
1408            rect.union(getRelativeBounds(mContent, focused));
1409        }
1410        mContent.requestRectangleOnScreen(rect);
1411    }
1412
1413    public void setAggregationSuggestionViewEnabled(boolean enabled) {
1414        if (mAggregationSuggestionView == null) {
1415            return;
1416        }
1417
1418        LinearLayout itemList = (LinearLayout) mAggregationSuggestionView.findViewById(
1419                R.id.aggregation_suggestions);
1420        int count = itemList.getChildCount();
1421        for (int i = 0; i < count; i++) {
1422            itemList.getChildAt(i).setEnabled(enabled);
1423        }
1424    }
1425
1426    /**
1427     * Computes bounds of the supplied view relative to its ascendant.
1428     */
1429    private Rect getRelativeBounds(View ascendant, View view) {
1430        Rect rect = new Rect();
1431        rect.set(view.getLeft(), view.getTop(), view.getRight(), view.getBottom());
1432
1433        View parent = (View) view.getParent();
1434        while (parent != ascendant) {
1435            rect.offset(parent.getLeft(), parent.getTop());
1436            parent = (View) parent.getParent();
1437        }
1438        return rect;
1439    }
1440
1441    @Override
1442    public void onSaveInstanceState(Bundle outState) {
1443        outState.putParcelable(KEY_URI, mLookupUri);
1444        outState.putString(KEY_ACTION, mAction);
1445
1446        if (hasValidState()) {
1447            // Store entities with modifications
1448            outState.putParcelable(KEY_EDIT_STATE, mState);
1449        }
1450
1451        outState.putLong(KEY_RAW_CONTACT_ID_REQUESTING_PHOTO, mRawContactIdRequestingPhoto);
1452        outState.putParcelable(KEY_VIEW_ID_GENERATOR, mViewIdGenerator);
1453        if (mCurrentPhotoFile != null) {
1454            outState.putString(KEY_CURRENT_PHOTO_FILE, mCurrentPhotoFile.toString());
1455        }
1456        outState.putLong(KEY_CONTACT_ID_FOR_JOIN, mContactIdForJoin);
1457        outState.putLong(KEY_SHOW_JOIN_SUGGESTIONS, mAggregationSuggestionsRawContactId);
1458        outState.putBoolean(KEY_ENABLED, mEnabled);
1459        outState.putInt(KEY_STATUS, mStatus);
1460        super.onSaveInstanceState(outState);
1461    }
1462
1463    @Override
1464    public void onActivityResult(int requestCode, int resultCode, Intent data) {
1465        if (mStatus == Status.SUB_ACTIVITY) {
1466            mStatus = Status.EDITING;
1467        }
1468
1469        // Ignore failed requests
1470        if (resultCode != Activity.RESULT_OK) return;
1471        switch (requestCode) {
1472            case REQUEST_CODE_PHOTO_PICKED_WITH_DATA: {
1473                // As we are coming back to this view, the editor will be reloaded automatically,
1474                // which will cause the photo that is set here to disappear. To prevent this,
1475                // we remember to set a flag which is interpreted after loading.
1476                // This photo is set here already to reduce flickering.
1477                mPhoto = data.getParcelableExtra("data");
1478                setPhoto(mRawContactIdRequestingPhoto, mPhoto);
1479                mRawContactIdRequestingPhotoAfterLoad = mRawContactIdRequestingPhoto;
1480                mRawContactIdRequestingPhoto = -1;
1481
1482                break;
1483            }
1484            case REQUEST_CODE_CAMERA_WITH_DATA: {
1485                doCropPhoto(mCurrentPhotoFile);
1486                break;
1487            }
1488            case REQUEST_CODE_JOIN: {
1489                if (data != null) {
1490                    final long contactId = ContentUris.parseId(data.getData());
1491                    joinAggregate(contactId);
1492                }
1493                break;
1494            }
1495        }
1496    }
1497
1498    /**
1499     * Sets the photo stored in mPhoto and writes it to the RawContact with the given id
1500     */
1501    private void setPhoto(long rawContact, Bitmap photo) {
1502        BaseRawContactEditorView requestingEditor = getRawContactEditorView(rawContact);
1503        if (requestingEditor != null) {
1504            requestingEditor.setPhotoBitmap(photo);
1505        } else {
1506            Log.w(TAG, "The contact that requested the photo is no longer present.");
1507        }
1508    }
1509
1510    /**
1511     * Finds raw contact editor view for the given rawContactId.
1512     */
1513    public BaseRawContactEditorView getRawContactEditorView(long rawContactId) {
1514        for (int i = 0; i < mContent.getChildCount(); i++) {
1515            final View childView = mContent.getChildAt(i);
1516            if (childView instanceof BaseRawContactEditorView) {
1517                final BaseRawContactEditorView editor = (BaseRawContactEditorView) childView;
1518                if (editor.getRawContactId() == rawContactId) {
1519                    return editor;
1520                }
1521            }
1522        }
1523        return null;
1524    }
1525
1526    /**
1527     * Returns true if there is currently more than one photo on screen.
1528     */
1529    private boolean hasMoreThanOnePhoto() {
1530        int count = mContent.getChildCount();
1531        int countWithPicture = 0;
1532        for (int i = 0; i < count; i++) {
1533            final View childView = mContent.getChildAt(i);
1534            if (childView instanceof BaseRawContactEditorView) {
1535                final BaseRawContactEditorView editor = (BaseRawContactEditorView) childView;
1536                if (editor.hasSetPhoto()) {
1537                    countWithPicture++;
1538                    if (countWithPicture > 1) return true;
1539                }
1540            }
1541        }
1542
1543        return false;
1544    }
1545
1546    /**
1547     * The listener for the data loader
1548     */
1549    private final LoaderManager.LoaderCallbacks<ContactLoader.Result> mDataLoaderListener =
1550            new LoaderCallbacks<ContactLoader.Result>() {
1551        @Override
1552        public Loader<ContactLoader.Result> onCreateLoader(int id, Bundle args) {
1553            mLoaderStartTime = SystemClock.elapsedRealtime();
1554            return new ContactLoader(mContext, mLookupUri);
1555        }
1556
1557        @Override
1558        public void onLoadFinished(Loader<ContactLoader.Result> loader, ContactLoader.Result data) {
1559            final long loaderCurrentTime = SystemClock.elapsedRealtime();
1560            Log.v(TAG, "Time needed for loading: " + (loaderCurrentTime-mLoaderStartTime));
1561            if (data == ContactLoader.Result.NOT_FOUND || data == ContactLoader.Result.ERROR) {
1562                // Item has been deleted
1563                Log.i(TAG, "No contact found. Closing activity");
1564                if (mListener != null) mListener.onContactNotFound();
1565                return;
1566            }
1567
1568            mStatus = Status.EDITING;
1569            mLookupUri = data.getLookupUri();
1570            final long setDataStartTime = SystemClock.elapsedRealtime();
1571            setData(data);
1572            final long setDataEndTime = SystemClock.elapsedRealtime();
1573
1574            // If we are coming back from the photo trimmer, this will be set.
1575            if (mRawContactIdRequestingPhotoAfterLoad != -1) {
1576                setPhoto(mRawContactIdRequestingPhotoAfterLoad, mPhoto);
1577                mRawContactIdRequestingPhotoAfterLoad = -1;
1578                mPhoto = null;
1579            }
1580            Log.v(TAG, "Time needed for setting UI: " + (setDataEndTime-setDataStartTime));
1581        }
1582
1583        public void onLoaderReset(Loader<ContactLoader.Result> loader) {
1584        }
1585    };
1586
1587    /**
1588     * The listener for the group meta data loader
1589     */
1590    private final LoaderManager.LoaderCallbacks<Cursor> mGroupLoaderListener =
1591            new LoaderCallbacks<Cursor>() {
1592
1593        @Override
1594        public CursorLoader onCreateLoader(int id, Bundle args) {
1595            return new GroupMetaDataLoader(mContext);
1596        }
1597
1598        @Override
1599        public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
1600            mGroupMetaData = data;
1601            bindGroupMetaData();
1602        }
1603
1604        public void onLoaderReset(Loader<Cursor> loader) {
1605        }
1606    };
1607
1608    @Override
1609    public void onSplitContactConfirmed() {
1610        mState.markRawContactsForSplitting();
1611        save(SaveMode.SPLIT);
1612    }
1613
1614    private final class PhotoEditorListener
1615            implements EditorListener, PhotoActionPopup.Listener {
1616        private final BaseRawContactEditorView mEditor;
1617        private final boolean mAccountReadOnly;
1618
1619        private PhotoEditorListener(BaseRawContactEditorView editor, boolean accountReadOnly) {
1620            mEditor = editor;
1621            mAccountReadOnly = accountReadOnly;
1622        }
1623
1624        @Override
1625        public void onRequest(int request) {
1626            if (!hasValidState()) return;
1627
1628            if (request == EditorListener.REQUEST_PICK_PHOTO) {
1629                // Determine mode
1630                final int mode;
1631                if (mAccountReadOnly) {
1632                    if (mEditor.hasSetPhoto() && hasMoreThanOnePhoto()) {
1633                        mode = PhotoActionPopup.MODE_READ_ONLY_ALLOW_PRIMARY;
1634                    } else {
1635                        // Read-only and either no photo or the only photo ==> no options
1636                        return;
1637                    }
1638                } else {
1639                    if (mEditor.hasSetPhoto()) {
1640                        if (hasMoreThanOnePhoto()) {
1641                            mode = PhotoActionPopup.MODE_PHOTO_ALLOW_PRIMARY;
1642                        } else {
1643                            mode = PhotoActionPopup.MODE_PHOTO_DISALLOW_PRIMARY;
1644                        }
1645                    } else {
1646                        mode = PhotoActionPopup.MODE_NO_PHOTO;
1647                    }
1648                }
1649                PhotoActionPopup.createPopupMenu(mContext, mEditor.getPhotoEditor(), this, mode)
1650                        .show();
1651            }
1652        }
1653
1654        @Override
1655        public void onDeleted(Editor removedEditor) {
1656        }
1657
1658        /**
1659         * User has chosen to set the selected photo as the (super) primary photo
1660         */
1661        @Override
1662        public void onUseAsPrimaryChosen() {
1663            // Set the IsSuperPrimary for each editor
1664            int count = mContent.getChildCount();
1665            for (int i = 0; i < count; i++) {
1666                final View childView = mContent.getChildAt(i);
1667                if (childView instanceof BaseRawContactEditorView) {
1668                    final BaseRawContactEditorView editor = (BaseRawContactEditorView) childView;
1669                    final PhotoEditorView photoEditor = editor.getPhotoEditor();
1670                    photoEditor.setSuperPrimary(editor == mEditor);
1671                }
1672            }
1673        }
1674
1675        /**
1676         * User has chosen to remove a picture
1677         */
1678        @Override
1679        public void onRemovePictureChose() {
1680            mEditor.setPhotoBitmap(null);
1681        }
1682
1683        /**
1684         * Launches Camera to take a picture and store it in a file.
1685         */
1686        @Override
1687        public void onTakePhotoChosen() {
1688            mRawContactIdRequestingPhoto = mEditor.getRawContactId();
1689            try {
1690                // Launch camera to take photo for selected contact
1691                PHOTO_DIR.mkdirs();
1692                mCurrentPhotoFile = new File(PHOTO_DIR, getPhotoFileName());
1693                final Intent intent = getTakePickIntent(mCurrentPhotoFile);
1694
1695                mStatus = Status.SUB_ACTIVITY;
1696                startActivityForResult(intent, REQUEST_CODE_CAMERA_WITH_DATA);
1697            } catch (ActivityNotFoundException e) {
1698                Toast.makeText(mContext, R.string.photoPickerNotFoundText,
1699                        Toast.LENGTH_LONG).show();
1700            }
1701        }
1702
1703        /**
1704         * Launches Gallery to pick a photo.
1705         */
1706        @Override
1707        public void onPickFromGalleryChosen() {
1708            mRawContactIdRequestingPhoto = mEditor.getRawContactId();
1709            try {
1710                // Launch picker to choose photo for selected contact
1711                final Intent intent = getPhotoPickIntent();
1712                mStatus = Status.SUB_ACTIVITY;
1713                startActivityForResult(intent, REQUEST_CODE_PHOTO_PICKED_WITH_DATA);
1714            } catch (ActivityNotFoundException e) {
1715                Toast.makeText(mContext, R.string.photoPickerNotFoundText,
1716                        Toast.LENGTH_LONG).show();
1717            }
1718        }
1719    }
1720}
1721