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