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