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