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