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