1 /*
2  * Copyright (C) 2015 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 
17 package com.android.contacts.editor;
18 
19 import com.android.contacts.common.logging.ScreenEvent.ScreenType;
20 import com.google.common.collect.ImmutableList;
21 import com.google.common.collect.Lists;
22 
23 import com.android.contacts.ContactSaveService;
24 import com.android.contacts.GroupMetaDataLoader;
25 import com.android.contacts.R;
26 import com.android.contacts.activities.ContactEditorAccountsChangedActivity;
27 import com.android.contacts.activities.ContactEditorBaseActivity;
28 import com.android.contacts.activities.ContactEditorBaseActivity.ContactEditor;
29 import com.android.contacts.common.model.AccountTypeManager;
30 import com.android.contacts.common.model.Contact;
31 import com.android.contacts.common.model.ContactLoader;
32 import com.android.contacts.common.model.RawContact;
33 import com.android.contacts.common.model.RawContactDelta;
34 import com.android.contacts.common.model.RawContactDeltaList;
35 import com.android.contacts.common.model.RawContactModifier;
36 import com.android.contacts.common.model.ValuesDelta;
37 import com.android.contacts.common.model.account.AccountType;
38 import com.android.contacts.common.model.account.AccountWithDataSet;
39 import com.android.contacts.common.util.ImplicitIntentsUtil;
40 import com.android.contacts.common.util.MaterialColorMapUtils;
41 import com.android.contacts.editor.AggregationSuggestionEngine.Suggestion;
42 import com.android.contacts.list.UiIntentActions;
43 import com.android.contacts.quickcontact.QuickContactActivity;
44 import com.android.contacts.util.HelpUtils;
45 import com.android.contacts.util.PhoneCapabilityTester;
46 import com.android.contacts.util.UiClosables;
47 
48 import android.accounts.Account;
49 import android.app.Activity;
50 import android.app.Fragment;
51 import android.app.LoaderManager;
52 import android.content.ActivityNotFoundException;
53 import android.content.ContentUris;
54 import android.content.ContentValues;
55 import android.content.Context;
56 import android.content.CursorLoader;
57 import android.content.Intent;
58 import android.content.Loader;
59 import android.database.Cursor;
60 import android.media.RingtoneManager;
61 import android.net.Uri;
62 import android.os.Bundle;
63 import android.os.SystemClock;
64 import android.provider.ContactsContract;
65 import android.provider.ContactsContract.CommonDataKinds.Email;
66 import android.provider.ContactsContract.CommonDataKinds.Event;
67 import android.provider.ContactsContract.CommonDataKinds.Organization;
68 import android.provider.ContactsContract.CommonDataKinds.Phone;
69 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
70 import android.provider.ContactsContract.CommonDataKinds.StructuredPostal;
71 import android.provider.ContactsContract.Contacts;
72 import android.provider.ContactsContract.Intents;
73 import android.provider.ContactsContract.RawContacts;
74 import android.util.Log;
75 import android.view.LayoutInflater;
76 import android.view.Menu;
77 import android.view.MenuInflater;
78 import android.view.MenuItem;
79 import android.view.View;
80 import android.view.ViewGroup;
81 import android.widget.AdapterView;
82 import android.widget.BaseAdapter;
83 import android.widget.LinearLayout;
84 import android.widget.ListPopupWindow;
85 import android.widget.Toast;
86 
87 import java.util.ArrayList;
88 import java.util.HashSet;
89 import java.util.Iterator;
90 import java.util.List;
91 import java.util.Set;
92 
93 /**
94  * Base Fragment for contact editors.
95  */
96 abstract public class ContactEditorBaseFragment extends Fragment implements
97         ContactEditor, SplitContactConfirmationDialogFragment.Listener,
98         JoinContactConfirmationDialogFragment.Listener,
99         AggregationSuggestionEngine.Listener, AggregationSuggestionView.Listener,
100         CancelEditDialogFragment.Listener {
101 
102     static final String TAG = "ContactEditor";
103 
104     protected static final int LOADER_CONTACT = 1;
105     protected static final int LOADER_GROUPS = 2;
106 
107     private static final List<String> VALID_INTENT_ACTIONS = new ArrayList<String>() {{
108         add(Intent.ACTION_EDIT);
109         add(Intent.ACTION_INSERT);
110         add(ContactEditorBaseActivity.ACTION_EDIT);
111         add(ContactEditorBaseActivity.ACTION_INSERT);
112         add(ContactEditorBaseActivity.ACTION_SAVE_COMPLETED);
113     }};
114 
115     private static final String KEY_ACTION = "action";
116     private static final String KEY_URI = "uri";
117     private static final String KEY_AUTO_ADD_TO_DEFAULT_GROUP = "autoAddToDefaultGroup";
118     private static final String KEY_DISABLE_DELETE_MENU_OPTION = "disableDeleteMenuOption";
119     private static final String KEY_NEW_LOCAL_PROFILE = "newLocalProfile";
120     private static final String KEY_MATERIAL_PALETTE = "materialPalette";
121     private static final String KEY_PHOTO_ID = "photoId";
122 
123     private static final String KEY_VIEW_ID_GENERATOR = "viewidgenerator";
124 
125     private static final String KEY_RAW_CONTACTS = "rawContacts";
126 
127     private static final String KEY_EDIT_STATE = "state";
128     private static final String KEY_STATUS = "status";
129 
130     private static final String KEY_HAS_NEW_CONTACT = "hasNewContact";
131     private static final String KEY_NEW_CONTACT_READY = "newContactDataReady";
132 
133     private static final String KEY_IS_EDIT = "isEdit";
134     private static final String KEY_EXISTING_CONTACT_READY = "existingContactDataReady";
135 
136     private static final String KEY_RAW_CONTACT_DISPLAY_ALONE_IS_READ_ONLY = "isReadOnly";
137 
138     // Phone option menus
139     private static final String KEY_SEND_TO_VOICE_MAIL_STATE = "sendToVoicemailState";
140     private static final String KEY_ARE_PHONE_OPTIONS_CHANGEABLE = "arePhoneOptionsChangable";
141     private static final String KEY_CUSTOM_RINGTONE = "customRingtone";
142 
143     private static final String KEY_IS_USER_PROFILE = "isUserProfile";
144 
145     private static final String KEY_ENABLED = "enabled";
146 
147     // Aggregation PopupWindow
148     private static final String KEY_AGGREGATION_SUGGESTIONS_RAW_CONTACT_ID =
149             "aggregationSuggestionsRawContactId";
150 
151     // Join Activity
152     private static final String KEY_CONTACT_ID_FOR_JOIN = "contactidforjoin";
153 
154     private static final String KEY_READ_ONLY_DISPLAY_NAME = "readOnlyDisplayName";
155 
156     protected static final int REQUEST_CODE_JOIN = 0;
157     protected static final int REQUEST_CODE_ACCOUNTS_CHANGED = 1;
158     protected static final int REQUEST_CODE_PICK_RINGTONE = 2;
159 
160     private static final int CURRENT_API_VERSION = android.os.Build.VERSION.SDK_INT;
161 
162     /**
163      * An intent extra that forces the editor to add the edited contact
164      * to the default group (e.g. "My Contacts").
165      */
166     public static final String INTENT_EXTRA_ADD_TO_DEFAULT_DIRECTORY = "addToDefaultDirectory";
167 
168     public static final String INTENT_EXTRA_NEW_LOCAL_PROFILE = "newLocalProfile";
169 
170     public static final String INTENT_EXTRA_DISABLE_DELETE_MENU_OPTION =
171             "disableDeleteMenuOption";
172 
173     /**
174      * Intent key to pass the photo palette primary color calculated by
175      * {@link com.android.contacts.quickcontact.QuickContactActivity} to the editor and between
176      * the compact and fully expanded editors.
177      */
178     public static final String INTENT_EXTRA_MATERIAL_PALETTE_PRIMARY_COLOR =
179             "material_palette_primary_color";
180 
181     /**
182      * Intent key to pass the photo palette secondary color calculated by
183      * {@link com.android.contacts.quickcontact.QuickContactActivity} to the editor and between
184      * the compact and fully expanded editors.
185      */
186     public static final String INTENT_EXTRA_MATERIAL_PALETTE_SECONDARY_COLOR =
187             "material_palette_secondary_color";
188 
189     /**
190      * Intent key to pass the ID of the photo to display on the editor.
191      */
192     public static final String INTENT_EXTRA_PHOTO_ID = "photo_id";
193 
194     /**
195      * Intent key to pass the ID of the raw contact id that should be displayed in the full editor
196      * by itself.
197      */
198     public static final String INTENT_EXTRA_RAW_CONTACT_ID_TO_DISPLAY_ALONE =
199             "raw_contact_id_to_display_alone";
200 
201     /**
202      * Intent key to pass the boolean value of if the raw contact id that should be displayed
203      * in the full editor by itself is read-only.
204      */
205     public static final String INTENT_EXTRA_RAW_CONTACT_DISPLAY_ALONE_IS_READ_ONLY =
206             "raw_contact_display_alone_is_read_only";
207 
208     /**
209      * Intent extra to specify a {@link ContactEditor.SaveMode}.
210      */
211     public static final String SAVE_MODE_EXTRA_KEY = "saveMode";
212 
213     /**
214      * Intent extra key for the contact ID to join the current contact to after saving.
215      */
216     public static final String JOIN_CONTACT_ID_EXTRA_KEY = "joinContactId";
217 
218     /**
219      * Callbacks for Activities that host contact editors Fragments.
220      */
221     public interface Listener {
222 
223         /**
224          * Contact was not found, so somehow close this fragment. This is raised after a contact
225          * is removed via Menu/Delete
226          */
onContactNotFound()227         void onContactNotFound();
228 
229         /**
230          * Contact was split, so we can close now.
231          *
232          * @param newLookupUri The lookup uri of the new contact that should be shown to the user.
233          *                     The editor tries best to chose the most natural contact here.
234          */
onContactSplit(Uri newLookupUri)235         void onContactSplit(Uri newLookupUri);
236 
237         /**
238          * User has tapped Revert, close the fragment now.
239          */
onReverted()240         void onReverted();
241 
242         /**
243          * Contact was saved and the Fragment can now be closed safely.
244          */
onSaveFinished(Intent resultIntent)245         void onSaveFinished(Intent resultIntent);
246 
247         /**
248          * User switched to editing a different contact (a suggestion from the
249          * aggregation engine).
250          */
onEditOtherContactRequested(Uri contactLookupUri, ArrayList<ContentValues> contentValues)251         void onEditOtherContactRequested(Uri contactLookupUri,
252                 ArrayList<ContentValues> contentValues);
253 
254         /**
255          * Contact is being created for an external account that provides its own
256          * new contact activity.
257          */
onCustomCreateContactActivityRequested(AccountWithDataSet account, Bundle intentExtras)258         void onCustomCreateContactActivityRequested(AccountWithDataSet account,
259                 Bundle intentExtras);
260 
261         /**
262          * The edited raw contact belongs to an external account that provides
263          * its own edit activity.
264          *
265          * @param redirect indicates that the current editor should be closed
266          *                 before the custom editor is shown.
267          */
onCustomEditContactActivityRequested(AccountWithDataSet account, Uri rawContactUri, Bundle intentExtras, boolean redirect)268         void onCustomEditContactActivityRequested(AccountWithDataSet account, Uri rawContactUri,
269                 Bundle intentExtras, boolean redirect);
270 
271         /**
272          * User has requested that contact be deleted.
273          */
onDeleteRequested(Uri contactUri)274         void onDeleteRequested(Uri contactUri);
275     }
276 
277     /**
278      * Adapter for aggregation suggestions displayed in a PopupWindow when
279      * editor fields change.
280      */
281     protected static final class AggregationSuggestionAdapter extends BaseAdapter {
282         private final LayoutInflater mLayoutInflater;
283         private final boolean mSetNewContact;
284         private final AggregationSuggestionView.Listener mListener;
285         private final List<AggregationSuggestionEngine.Suggestion> mSuggestions;
286 
AggregationSuggestionAdapter(Activity activity, boolean setNewContact, AggregationSuggestionView.Listener listener, List<Suggestion> suggestions)287         public AggregationSuggestionAdapter(Activity activity, boolean setNewContact,
288                 AggregationSuggestionView.Listener listener, List<Suggestion> suggestions) {
289             mLayoutInflater = activity.getLayoutInflater();
290             mSetNewContact = setNewContact;
291             mListener = listener;
292             mSuggestions = suggestions;
293         }
294 
295         @Override
getView(int position, View convertView, ViewGroup parent)296         public View getView(int position, View convertView, ViewGroup parent) {
297             final Suggestion suggestion = (Suggestion) getItem(position);
298             final AggregationSuggestionView suggestionView =
299                     (AggregationSuggestionView) mLayoutInflater.inflate(
300                             R.layout.aggregation_suggestions_item, null);
301             suggestionView.setNewContact(mSetNewContact);
302             suggestionView.setListener(mListener);
303             suggestionView.bindSuggestion(suggestion);
304             return suggestionView;
305         }
306 
307         @Override
getItemId(int position)308         public long getItemId(int position) {
309             return position;
310         }
311 
312         @Override
getItem(int position)313         public Object getItem(int position) {
314             return mSuggestions.get(position);
315         }
316 
317         @Override
getCount()318         public int getCount() {
319             return mSuggestions.size();
320         }
321     }
322 
323     protected Context mContext;
324     protected Listener mListener;
325 
326     //
327     // Views
328     //
329     protected LinearLayout mContent;
330     protected View mAggregationSuggestionView;
331     protected ListPopupWindow mAggregationSuggestionPopup;
332 
333     //
334     // Parameters passed in on {@link #load}
335     //
336     protected String mAction;
337     protected Uri mLookupUri;
338     protected Bundle mIntentExtras;
339     protected boolean mAutoAddToDefaultGroup;
340     protected boolean mDisableDeleteMenuOption;
341     protected boolean mNewLocalProfile;
342     protected MaterialColorMapUtils.MaterialPalette mMaterialPalette;
343     protected long mPhotoId = -1;
344 
345     //
346     // Helpers
347     //
348     protected ContactEditorUtils mEditorUtils;
349     protected RawContactDeltaComparator mComparator;
350     protected ViewIdGenerator mViewIdGenerator;
351     private AggregationSuggestionEngine mAggregationSuggestionEngine;
352 
353     //
354     // Loaded data
355     //
356     // Used to store existing contact data so it can be re-applied during a rebind call,
357     // i.e. account switch.  Only used in {@link ContactEditorFragment}.
358     protected ImmutableList<RawContact> mRawContacts;
359     protected Cursor mGroupMetaData;
360 
361     //
362     // Editor state
363     //
364     protected RawContactDeltaList mState;
365     protected int mStatus;
366     protected long mRawContactIdToDisplayAlone = -1;
367     protected boolean mRawContactDisplayAloneIsReadOnly = false;
368 
369     // Whether to show the new contact blank form and if it's corresponding delta is ready.
370     protected boolean mHasNewContact;
371     protected AccountWithDataSet mAccountWithDataSet;
372     protected boolean mNewContactDataReady;
373     protected boolean mNewContactAccountChanged;
374 
375     // Whether it's an edit of existing contact and if it's corresponding delta is ready.
376     protected boolean mIsEdit;
377     protected boolean mExistingContactDataReady;
378 
379     // Whether we are editing the "me" profile
380     protected boolean mIsUserProfile;
381 
382     // Phone specific option menu items
383     private boolean mSendToVoicemailState;
384     private boolean mArePhoneOptionsChangable;
385     private String mCustomRingtone;
386 
387     // Whether editor views and options menu items should be enabled
388     private boolean mEnabled = true;
389 
390     // Aggregation PopupWindow
391     private long mAggregationSuggestionsRawContactId;
392 
393     // Join Activity
394     protected long mContactIdForJoin;
395 
396     // Used to pre-populate the editor with a display name when a user edits a read-only contact.
397     protected String mReadOnlyDisplayName;
398 
399     //
400     // Not saved/restored on rotates
401     //
402 
403     // The name editor view for the new raw contact that was created so that the user can
404     // edit a read-only contact (to which the new raw contact was joined)
405     protected StructuredNameEditorView mReadOnlyNameEditorView;
406 
407     /**
408      * The contact data loader listener.
409      */
410     protected final LoaderManager.LoaderCallbacks<Contact> mContactLoaderListener =
411             new LoaderManager.LoaderCallbacks<Contact>() {
412 
413                 protected long mLoaderStartTime;
414 
415                 @Override
416                 public Loader<Contact> onCreateLoader(int id, Bundle args) {
417                     mLoaderStartTime = SystemClock.elapsedRealtime();
418                     return new ContactLoader(mContext, mLookupUri, true);
419                 }
420 
421                 @Override
422                 public void onLoadFinished(Loader<Contact> loader, Contact contact) {
423                     final long loaderCurrentTime = SystemClock.elapsedRealtime();
424                     Log.v(TAG, "Time needed for loading: " + (loaderCurrentTime-mLoaderStartTime));
425                     if (!contact.isLoaded()) {
426                         // Item has been deleted. Close activity without saving again.
427                         Log.i(TAG, "No contact found. Closing activity");
428                         mStatus = Status.CLOSING;
429                         if (mListener != null) mListener.onContactNotFound();
430                         return;
431                     }
432 
433                     mStatus = Status.EDITING;
434                     mLookupUri = contact.getLookupUri();
435                     final long setDataStartTime = SystemClock.elapsedRealtime();
436                     setState(contact);
437                     setStateForPhoneMenuItems(contact);
438                     final long setDataEndTime = SystemClock.elapsedRealtime();
439 
440                     Log.v(TAG, "Time needed for setting UI: " + (setDataEndTime - setDataStartTime));
441                 }
442 
443                 @Override
444                 public void onLoaderReset(Loader<Contact> loader) {
445                 }
446             };
447 
448     /**
449      * The groups meta data loader listener.
450      */
451     protected final LoaderManager.LoaderCallbacks<Cursor> mGroupsLoaderListener =
452             new LoaderManager.LoaderCallbacks<Cursor>() {
453 
454                 @Override
455                 public CursorLoader onCreateLoader(int id, Bundle args) {
456                     return new GroupMetaDataLoader(mContext, ContactsContract.Groups.CONTENT_URI);
457                 }
458 
459                 @Override
460                 public void onLoadFinished(Loader<Cursor> loader, Cursor data) {
461                     mGroupMetaData = data;
462                     setGroupMetaData();
463                 }
464 
465                 @Override
466                 public void onLoaderReset(Loader<Cursor> loader) {
467                 }
468             };
469 
470     @Override
onAttach(Activity activity)471     public void onAttach(Activity activity) {
472         super.onAttach(activity);
473         mContext = activity;
474         mEditorUtils = ContactEditorUtils.getInstance(mContext);
475         mComparator = new RawContactDeltaComparator(mContext);
476     }
477 
478     @Override
onCreate(Bundle savedState)479     public void onCreate(Bundle savedState) {
480         if (savedState != null) {
481             // Restore mUri before calling super.onCreate so that onInitializeLoaders
482             // would already have a uri and an action to work with
483             mAction = savedState.getString(KEY_ACTION);
484             mLookupUri = savedState.getParcelable(KEY_URI);
485         }
486 
487         super.onCreate(savedState);
488 
489         if (savedState == null) {
490             mViewIdGenerator = new ViewIdGenerator();
491         } else {
492             mViewIdGenerator = savedState.getParcelable(KEY_VIEW_ID_GENERATOR);
493 
494             mAutoAddToDefaultGroup = savedState.getBoolean(KEY_AUTO_ADD_TO_DEFAULT_GROUP);
495             mDisableDeleteMenuOption = savedState.getBoolean(KEY_DISABLE_DELETE_MENU_OPTION);
496             mNewLocalProfile = savedState.getBoolean(KEY_NEW_LOCAL_PROFILE);
497             mMaterialPalette = savedState.getParcelable(KEY_MATERIAL_PALETTE);
498             mPhotoId = savedState.getLong(KEY_PHOTO_ID);
499 
500             mRawContacts = ImmutableList.copyOf(savedState.<RawContact>getParcelableArrayList(
501                     KEY_RAW_CONTACTS));
502             // NOTE: mGroupMetaData is not saved/restored
503 
504             // Read state from savedState. No loading involved here
505             mState = savedState.<RawContactDeltaList> getParcelable(KEY_EDIT_STATE);
506             mStatus = savedState.getInt(KEY_STATUS);
507             mRawContactDisplayAloneIsReadOnly = savedState.getBoolean(
508                     KEY_RAW_CONTACT_DISPLAY_ALONE_IS_READ_ONLY);
509 
510             mHasNewContact = savedState.getBoolean(KEY_HAS_NEW_CONTACT);
511             mNewContactDataReady = savedState.getBoolean(KEY_NEW_CONTACT_READY);
512 
513             mIsEdit = savedState.getBoolean(KEY_IS_EDIT);
514             mExistingContactDataReady = savedState.getBoolean(KEY_EXISTING_CONTACT_READY);
515 
516             mIsUserProfile = savedState.getBoolean(KEY_IS_USER_PROFILE);
517 
518             // Phone specific options menus
519             mSendToVoicemailState = savedState.getBoolean(KEY_SEND_TO_VOICE_MAIL_STATE);
520             mArePhoneOptionsChangable = savedState.getBoolean(KEY_ARE_PHONE_OPTIONS_CHANGEABLE);
521             mCustomRingtone = savedState.getString(KEY_CUSTOM_RINGTONE);
522 
523             mEnabled = savedState.getBoolean(KEY_ENABLED);
524 
525             // Aggregation PopupWindow
526             mAggregationSuggestionsRawContactId = savedState.getLong(
527                     KEY_AGGREGATION_SUGGESTIONS_RAW_CONTACT_ID);
528 
529             // Join Activity
530             mContactIdForJoin = savedState.getLong(KEY_CONTACT_ID_FOR_JOIN);
531 
532             mReadOnlyDisplayName = savedState.getString(KEY_READ_ONLY_DISPLAY_NAME);
533         }
534 
535         // mState can still be null because it may not have have finished loading before
536         // onSaveInstanceState was called.
537         if (mState == null) {
538             mState = new RawContactDeltaList();
539         }
540     }
541 
542     @Override
onActivityCreated(Bundle savedInstanceState)543     public void onActivityCreated(Bundle savedInstanceState) {
544         super.onActivityCreated(savedInstanceState);
545 
546         validateAction(mAction);
547 
548         if (mState.isEmpty()) {
549             // The delta list may not have finished loading before orientation change happens.
550             // In this case, there will be a saved state but deltas will be missing.  Reload from
551             // database.
552             if (Intent.ACTION_EDIT.equals(mAction) ||
553                     ContactEditorBaseActivity.ACTION_EDIT.equals(mAction)) {
554                 // Either
555                 // 1) orientation change but load never finished.
556                 // 2) not an orientation change so data needs to be loaded for first time.
557                 getLoaderManager().initLoader(LOADER_CONTACT, null, mContactLoaderListener);
558                 getLoaderManager().initLoader(LOADER_GROUPS, null, mGroupsLoaderListener);
559             }
560         } else {
561             // Orientation change, we already have mState, it was loaded by onCreate
562             bindEditors();
563         }
564 
565         // Handle initial actions only when existing state missing
566         if (savedInstanceState == null) {
567             final Account account = mIntentExtras == null ? null :
568                     (Account) mIntentExtras.getParcelable(Intents.Insert.EXTRA_ACCOUNT);
569             final String dataSet = mIntentExtras == null ? null :
570                     mIntentExtras.getString(Intents.Insert.EXTRA_DATA_SET);
571             if (account != null) {
572                 mAccountWithDataSet = new AccountWithDataSet(account.name, account.type, dataSet);
573             }
574 
575             if (Intent.ACTION_EDIT.equals(mAction) ||
576                     ContactEditorBaseActivity.ACTION_EDIT.equals(mAction)) {
577                 mIsEdit = true;
578             } else if (Intent.ACTION_INSERT.equals(mAction) ||
579                     ContactEditorBaseActivity.ACTION_INSERT.equals(mAction)) {
580                 mHasNewContact = true;
581                 if (mAccountWithDataSet != null) {
582                     createContact(mAccountWithDataSet);
583                 } else {
584                     // No Account specified. Let the user choose
585                     // Load Accounts async so that we can present them
586                     selectAccountAndCreateContact();
587                 }
588             }
589         }
590     }
591 
592     /**
593      * Checks if the requested action is valid.
594      *
595      * @param action The action to test.
596      * @throws IllegalArgumentException when the action is invalid.
597      */
validateAction(String action)598     private static void validateAction(String action) {
599         if (VALID_INTENT_ACTIONS.contains(action)) {
600             return;
601         }
602         throw new IllegalArgumentException(
603                 "Unknown action " + action + "; Supported actions: " + VALID_INTENT_ACTIONS);
604     }
605 
606     @Override
onSaveInstanceState(Bundle outState)607     public void onSaveInstanceState(Bundle outState) {
608         outState.putString(KEY_ACTION, mAction);
609         outState.putParcelable(KEY_URI, mLookupUri);
610         outState.putBoolean(KEY_AUTO_ADD_TO_DEFAULT_GROUP, mAutoAddToDefaultGroup);
611         outState.putBoolean(KEY_DISABLE_DELETE_MENU_OPTION, mDisableDeleteMenuOption);
612         outState.putBoolean(KEY_NEW_LOCAL_PROFILE, mNewLocalProfile);
613         if (mMaterialPalette != null) {
614             outState.putParcelable(KEY_MATERIAL_PALETTE, mMaterialPalette);
615         }
616         outState.putLong(KEY_PHOTO_ID, mPhotoId);
617 
618         outState.putParcelable(KEY_VIEW_ID_GENERATOR, mViewIdGenerator);
619 
620         outState.putParcelableArrayList(KEY_RAW_CONTACTS, mRawContacts == null ?
621                 Lists.<RawContact>newArrayList() : Lists.newArrayList(mRawContacts));
622         // NOTE: mGroupMetaData is not saved
623 
624         if (hasValidState()) {
625             // Store entities with modifications
626             outState.putParcelable(KEY_EDIT_STATE, mState);
627         }
628         outState.putInt(KEY_STATUS, mStatus);
629         outState.putBoolean(KEY_HAS_NEW_CONTACT, mHasNewContact);
630         outState.putBoolean(KEY_NEW_CONTACT_READY, mNewContactDataReady);
631         outState.putBoolean(KEY_IS_EDIT, mIsEdit);
632         outState.putBoolean(KEY_EXISTING_CONTACT_READY, mExistingContactDataReady);
633         outState.putBoolean(KEY_RAW_CONTACT_DISPLAY_ALONE_IS_READ_ONLY,
634                 mRawContactDisplayAloneIsReadOnly);
635 
636         outState.putBoolean(KEY_IS_USER_PROFILE, mIsUserProfile);
637 
638         // Phone specific options
639         outState.putBoolean(KEY_SEND_TO_VOICE_MAIL_STATE, mSendToVoicemailState);
640         outState.putBoolean(KEY_ARE_PHONE_OPTIONS_CHANGEABLE, mArePhoneOptionsChangable);
641         outState.putString(KEY_CUSTOM_RINGTONE, mCustomRingtone);
642 
643         outState.putBoolean(KEY_ENABLED, mEnabled);
644 
645         // Aggregation PopupWindow
646         outState.putLong(KEY_AGGREGATION_SUGGESTIONS_RAW_CONTACT_ID,
647                 mAggregationSuggestionsRawContactId);
648 
649         // Join Activity
650         outState.putLong(KEY_CONTACT_ID_FOR_JOIN, mContactIdForJoin);
651 
652         outState.putString(KEY_READ_ONLY_DISPLAY_NAME, mReadOnlyDisplayName);
653 
654         super.onSaveInstanceState(outState);
655     }
656 
657     @Override
onStop()658     public void onStop() {
659         super.onStop();
660         UiClosables.closeQuietly(mAggregationSuggestionPopup);
661     }
662 
663     @Override
onDestroy()664     public void onDestroy() {
665         super.onDestroy();
666         if (mAggregationSuggestionEngine != null) {
667             mAggregationSuggestionEngine.quit();
668         }
669     }
670 
671     @Override
onActivityResult(int requestCode, int resultCode, Intent data)672     public void onActivityResult(int requestCode, int resultCode, Intent data) {
673         switch (requestCode) {
674             case REQUEST_CODE_JOIN: {
675                 // Ignore failed requests
676                 if (resultCode != Activity.RESULT_OK) return;
677                 if (data != null) {
678                     final long contactId = ContentUris.parseId(data.getData());
679                     if (hasPendingChanges()) {
680                         // Ask the user if they want to save changes before doing the join
681                         JoinContactConfirmationDialogFragment.show(this, contactId);
682                     } else {
683                         // Do the join immediately
684                         joinAggregate(contactId);
685                     }
686                 }
687                 break;
688             }
689             case REQUEST_CODE_ACCOUNTS_CHANGED: {
690                 // Bail if the account selector was not successful.
691                 if (resultCode != Activity.RESULT_OK) {
692                     if (mListener != null) {
693                         mListener.onReverted();
694                     }
695                     return;
696                 }
697                 // If there's an account specified, use it.
698                 if (data != null) {
699                     AccountWithDataSet account = data.getParcelableExtra(
700                             Intents.Insert.EXTRA_ACCOUNT);
701                     if (account != null) {
702                         createContact(account);
703                         return;
704                     }
705                 }
706                 // If there isn't an account specified, then this is likely a phone-local
707                 // contact, so we should continue setting up the editor by automatically selecting
708                 // the most appropriate account.
709                 createContact();
710                 break;
711             }
712             case REQUEST_CODE_PICK_RINGTONE: {
713                 if (data != null) {
714                     final Uri pickedUri = data.getParcelableExtra(
715                             RingtoneManager.EXTRA_RINGTONE_PICKED_URI);
716                     onRingtonePicked(pickedUri);
717                 }
718                 break;
719             }
720         }
721     }
722 
onRingtonePicked(Uri pickedUri)723     private void onRingtonePicked(Uri pickedUri) {
724         mCustomRingtone = EditorUiUtils.getRingtoneStringFromUri(pickedUri, CURRENT_API_VERSION);
725         Intent intent = ContactSaveService.createSetRingtone(
726                 mContext, mLookupUri, mCustomRingtone);
727         mContext.startService(intent);
728     }
729 
730     //
731     // Options menu
732     //
733 
setStateForPhoneMenuItems(Contact contact)734     private void setStateForPhoneMenuItems(Contact contact) {
735         if (contact != null) {
736             mSendToVoicemailState = contact.isSendToVoicemail();
737             mCustomRingtone = contact.getCustomRingtone();
738             mArePhoneOptionsChangable = !contact.isDirectoryEntry()
739                     && PhoneCapabilityTester.isPhone(mContext);
740         }
741     }
742 
743     /**
744      * Invalidates the options menu if we are still associated with an Activity.
745      */
invalidateOptionsMenu()746     protected void invalidateOptionsMenu() {
747         final Activity activity = getActivity();
748         if (activity != null) {
749             activity.invalidateOptionsMenu();
750         }
751     }
752 
753     @Override
onCreateOptionsMenu(Menu menu, final MenuInflater inflater)754     public void onCreateOptionsMenu(Menu menu, final MenuInflater inflater) {
755         inflater.inflate(R.menu.edit_contact, menu);
756     }
757 
758     @Override
onPrepareOptionsMenu(Menu menu)759     public void onPrepareOptionsMenu(Menu menu) {
760         // This supports the keyboard shortcut to save changes to a contact but shouldn't be visible
761         // because the custom action bar contains the "save" button now (not the overflow menu).
762         // TODO: Find a better way to handle shortcuts, i.e. onKeyDown()?
763         final MenuItem saveMenu = menu.findItem(R.id.menu_save);
764         final MenuItem splitMenu = menu.findItem(R.id.menu_split);
765         final MenuItem joinMenu = menu.findItem(R.id.menu_join);
766         final MenuItem helpMenu = menu.findItem(R.id.menu_help);
767         final MenuItem sendToVoiceMailMenu = menu.findItem(R.id.menu_send_to_voicemail);
768         final MenuItem ringToneMenu = menu.findItem(R.id.menu_set_ringtone);
769         final MenuItem deleteMenu = menu.findItem(R.id.menu_delete);
770 
771         // Set visibility of menus
772 
773         // help menu depending on whether this is inserting or editing
774         if (isInsert(mAction) || mRawContactIdToDisplayAlone != -1) {
775             HelpUtils.prepareHelpMenuItem(mContext, helpMenu, R.string.help_url_people_add);
776             splitMenu.setVisible(false);
777             joinMenu.setVisible(false);
778             deleteMenu.setVisible(false);
779         } else if (isEdit(mAction)) {
780             HelpUtils.prepareHelpMenuItem(mContext, helpMenu, R.string.help_url_people_edit);
781             splitMenu.setVisible(canUnlinkRawContacts());
782             // Cannot join a user profile
783             joinMenu.setVisible(!isEditingUserProfile());
784             deleteMenu.setVisible(!mDisableDeleteMenuOption && !isEditingUserProfile());
785         } else {
786             // something else, so don't show the help menu
787             helpMenu.setVisible(false);
788         }
789 
790         // Save menu is invisible when there's only one read only contact in the editor.
791         saveMenu.setVisible(!mRawContactDisplayAloneIsReadOnly);
792 
793         if (mRawContactIdToDisplayAlone != -1 || mIsUserProfile) {
794             sendToVoiceMailMenu.setVisible(false);
795             ringToneMenu.setVisible(false);
796         } else {
797             // Hide telephony-related settings (ringtone, send to voicemail)
798             // if we don't have a telephone or are editing a new contact.
799             sendToVoiceMailMenu.setChecked(mSendToVoicemailState);
800             sendToVoiceMailMenu.setVisible(mArePhoneOptionsChangable);
801             ringToneMenu.setVisible(mArePhoneOptionsChangable);
802         }
803 
804         int size = menu.size();
805         for (int i = 0; i < size; i++) {
806             menu.getItem(i).setEnabled(mEnabled);
807         }
808     }
809 
810     @Override
onOptionsItemSelected(MenuItem item)811     public boolean onOptionsItemSelected(MenuItem item) {
812         final Activity activity = getActivity();
813         if (activity == null || activity.isFinishing() || activity.isDestroyed()) {
814             // If we no longer are attached to a running activity want to
815             // drain this event.
816             return true;
817         }
818 
819         switch (item.getItemId()) {
820             case R.id.menu_save:
821                 return save(SaveMode.CLOSE);
822             case R.id.menu_delete:
823                 if (mListener != null) mListener.onDeleteRequested(mLookupUri);
824                 return true;
825             case R.id.menu_split:
826                 return doSplitContactAction();
827             case R.id.menu_join:
828                 return doJoinContactAction();
829             case R.id.menu_set_ringtone:
830                 doPickRingtone();
831                 return true;
832             case R.id.menu_send_to_voicemail:
833                 // Update state and save
834                 mSendToVoicemailState = !mSendToVoicemailState;
835                 item.setChecked(mSendToVoicemailState);
836                 final Intent intent = ContactSaveService.createSetSendToVoicemail(
837                         mContext, mLookupUri, mSendToVoicemailState);
838                 mContext.startService(intent);
839                 return true;
840         }
841 
842         return false;
843     }
844 
845     @Override
revert()846     public boolean revert() {
847         if (mState.isEmpty() || !hasPendingChanges()) {
848             onCancelEditConfirmed();
849         } else {
850             CancelEditDialogFragment.show(this);
851         }
852         return true;
853     }
854 
855     @Override
onCancelEditConfirmed()856     public void onCancelEditConfirmed() {
857         // When this Fragment is closed we don't want it to auto-save
858         mStatus = Status.CLOSING;
859         if (mListener != null) {
860             mListener.onReverted();
861         }
862     }
863 
864     @Override
onSplitContactConfirmed(boolean hasPendingChanges)865     public void onSplitContactConfirmed(boolean hasPendingChanges) {
866         if (mState.isEmpty()) {
867             // This may happen when this Fragment is recreated by the system during users
868             // confirming the split action (and thus this method is called just before onCreate()),
869             // for example.
870             Log.e(TAG, "mState became null during the user's confirming split action. " +
871                     "Cannot perform the save action.");
872             return;
873         }
874 
875         if (!hasPendingChanges && mHasNewContact) {
876             // If the user didn't add anything new, we don't want to split out the newly created
877             // raw contact into a name-only contact so remove them.
878             final Iterator<RawContactDelta> iterator = mState.iterator();
879             while (iterator.hasNext()) {
880                 final RawContactDelta rawContactDelta = iterator.next();
881                 if (rawContactDelta.getRawContactId() < 0) {
882                     iterator.remove();
883                 }
884             }
885         }
886         mState.markRawContactsForSplitting();
887         save(SaveMode.SPLIT);
888     }
889 
doSplitContactAction()890     private boolean doSplitContactAction() {
891         if (!hasValidState()) return false;
892 
893         SplitContactConfirmationDialogFragment.show(this, hasPendingChanges());
894         return true;
895     }
896 
doJoinContactAction()897     private boolean doJoinContactAction() {
898         if (!hasValidState() || mLookupUri == null) {
899             return false;
900         }
901 
902         // If we just started creating a new contact and haven't added any data, it's too
903         // early to do a join
904         if (mState.size() == 1 && mState.get(0).isContactInsert()
905                 && !hasPendingChanges()) {
906             Toast.makeText(mContext, R.string.toast_join_with_empty_contact,
907                     Toast.LENGTH_LONG).show();
908             return true;
909         }
910 
911         showJoinAggregateActivity(mLookupUri);
912         return true;
913     }
914 
915     @Override
onJoinContactConfirmed(long joinContactId)916     public void onJoinContactConfirmed(long joinContactId) {
917         doSaveAction(SaveMode.JOIN, joinContactId);
918     }
919 
doPickRingtone()920     private void doPickRingtone() {
921         final Intent intent = new Intent(RingtoneManager.ACTION_RINGTONE_PICKER);
922         // Allow user to pick 'Default'
923         intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_DEFAULT, true);
924         // Show only ringtones
925         intent.putExtra(RingtoneManager.EXTRA_RINGTONE_TYPE, RingtoneManager.TYPE_RINGTONE);
926         // Allow the user to pick a silent ringtone
927         intent.putExtra(RingtoneManager.EXTRA_RINGTONE_SHOW_SILENT, true);
928 
929         final Uri ringtoneUri = EditorUiUtils.getRingtoneUriFromString(mCustomRingtone,
930                 CURRENT_API_VERSION);
931 
932         // Put checkmark next to the current ringtone for this contact
933         intent.putExtra(RingtoneManager.EXTRA_RINGTONE_EXISTING_URI, ringtoneUri);
934 
935         // Launch!
936         try {
937             startActivityForResult(intent, REQUEST_CODE_PICK_RINGTONE);
938         } catch (ActivityNotFoundException ex) {
939             Toast.makeText(mContext, R.string.missing_app, Toast.LENGTH_SHORT).show();
940         }
941     }
942 
943     @Override
save(int saveMode)944     public boolean save(int saveMode) {
945         if (!hasValidState() || mStatus != Status.EDITING) {
946             return false;
947         }
948 
949         // If we are about to close the editor - there is no need to refresh the data
950         if (saveMode == SaveMode.CLOSE || saveMode == SaveMode.COMPACT
951                 || saveMode == SaveMode.SPLIT) {
952             getLoaderManager().destroyLoader(LOADER_CONTACT);
953         }
954 
955         mStatus = Status.SAVING;
956 
957         if (!hasPendingChanges()) {
958             if (mLookupUri == null && saveMode == SaveMode.RELOAD) {
959                 // We don't have anything to save and there isn't even an existing contact yet.
960                 // Nothing to do, simply go back to editing mode
961                 mStatus = Status.EDITING;
962                 return true;
963             }
964             onSaveCompleted(/* hadChanges =*/ false, saveMode,
965                     /* saveSucceeded =*/ mLookupUri != null, mLookupUri, /* joinContactId =*/ null);
966             return true;
967         }
968 
969         setEnabled(false);
970 
971         return doSaveAction(saveMode, /* joinContactId */ null);
972     }
973 
974     /**
975      * Persist the accumulated editor deltas.
976      *
977      * @param joinContactId the raw contact ID to join the contact being saved to after the save,
978      *         may be null.
979      */
doSaveAction(int saveMode, Long joinContactId)980     abstract protected boolean doSaveAction(int saveMode, Long joinContactId);
981 
startSaveService(Context context, Intent intent, int saveMode)982     protected boolean startSaveService(Context context, Intent intent, int saveMode) {
983         final boolean result = ContactSaveService.startService(
984                 context, intent, saveMode);
985         if (!result) {
986             onCancelEditConfirmed();
987         }
988         return result;
989     }
990 
991     //
992     // State accessor methods
993     //
994 
995     /**
996      * Check if our internal {@link #mState} is valid, usually checked before
997      * performing user actions.
998      */
hasValidState()999     protected boolean hasValidState() {
1000         return mState.size() > 0;
1001     }
1002 
isEditingUserProfile()1003     protected boolean isEditingUserProfile() {
1004         return mNewLocalProfile || mIsUserProfile;
1005     }
1006 
1007     /**
1008      * Whether the contact being edited spans multiple raw contacts.
1009      * The may also span multiple accounts.
1010      */
isEditingMultipleRawContacts()1011     public boolean isEditingMultipleRawContacts() {
1012         return mState.size() > 1;
1013     }
1014 
1015     /**
1016      * Whether the contact being edited is composed of a single read-only raw contact
1017      * aggregated with a newly created writable raw contact.
1018      */
isEditingReadOnlyRawContactWithNewContact()1019     protected boolean isEditingReadOnlyRawContactWithNewContact() {
1020         return mHasNewContact && mState.size() == 2;
1021     }
1022 
1023     /**
1024      * Return true if there are any edits to the current contact which need to
1025      * be saved.
1026      */
hasPendingRawContactChanges(Set<String> excludedMimeTypes)1027     protected boolean hasPendingRawContactChanges(Set<String> excludedMimeTypes) {
1028         final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
1029         return RawContactModifier.hasChanges(mState, accountTypes, excludedMimeTypes);
1030     }
1031 
1032     /**
1033      * We allow unlinking only if there is more than one raw contact, it is not a user-profile,
1034      * and unlinking won't result in an empty contact.  For the empty contact case, we only guard
1035      * against this when there is a single read-only contact in the aggregate.  If the user
1036      * has joined >1 read-only contacts together, we allow them to unlink it, even if they have
1037      * never added their own information and unlinking will create a name only contact.
1038      */
canUnlinkRawContacts()1039     protected boolean canUnlinkRawContacts() {
1040         return isEditingMultipleRawContacts()
1041                 && !isEditingUserProfile()
1042                 && !isEditingReadOnlyRawContactWithNewContact();
1043     }
1044 
1045     /**
1046      * Determines if changes were made in the editor that need to be saved, while taking into
1047      * account that name changes are not real for read-only contacts.
1048      * See go/editing-read-only-contacts
1049      */
hasPendingChanges()1050     protected boolean hasPendingChanges() {
1051         if (mReadOnlyNameEditorView != null && mReadOnlyDisplayName != null) {
1052             // We created a new raw contact delta with a default display name.
1053             // We must test for pending changes while ignoring the default display name.
1054             final String displayName = mReadOnlyNameEditorView.getDisplayName();
1055             if (mReadOnlyDisplayName.equals(displayName)) {
1056                 final Set<String> excludedMimeTypes = new HashSet<>();
1057                 excludedMimeTypes.add(StructuredName.CONTENT_ITEM_TYPE);
1058                 return hasPendingRawContactChanges(excludedMimeTypes);
1059             }
1060             return true;
1061         }
1062         return hasPendingRawContactChanges(/* excludedMimeTypes =*/ null);
1063     }
1064 
1065     /**
1066      * Whether editor inputs and the options menu should be enabled.
1067      */
isEnabled()1068     protected boolean isEnabled() {
1069         return mEnabled;
1070     }
1071 
1072     /**
1073      * Returns the palette extra that was passed in.
1074      */
getMaterialPalette()1075     protected MaterialColorMapUtils.MaterialPalette getMaterialPalette() {
1076         return mMaterialPalette;
1077     }
1078 
1079     //
1080     // Account creation
1081     //
1082 
selectAccountAndCreateContact()1083     private void selectAccountAndCreateContact() {
1084         // If this is a local profile, then skip the logic about showing the accounts changed
1085         // activity and create a phone-local contact.
1086         if (mNewLocalProfile) {
1087             createContact(null);
1088             return;
1089         }
1090 
1091         // If there is no default account or the accounts have changed such that we need to
1092         // prompt the user again, then launch the account prompt.
1093         if (mEditorUtils.shouldShowAccountChangedNotification()) {
1094             Intent intent = new Intent(mContext, ContactEditorAccountsChangedActivity.class);
1095             // Prevent a second instance from being started on rotates
1096             intent.setFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP);
1097             mStatus = Status.SUB_ACTIVITY;
1098             startActivityForResult(intent, REQUEST_CODE_ACCOUNTS_CHANGED);
1099         } else {
1100             // Otherwise, there should be a default account. Then either create a local contact
1101             // (if default account is null) or create a contact with the specified account.
1102             AccountWithDataSet defaultAccount = mEditorUtils.getDefaultAccount();
1103             createContact(defaultAccount);
1104         }
1105     }
1106 
1107     /**
1108      * Create a contact by automatically selecting the first account. If there's no available
1109      * account, a device-local contact should be created.
1110      */
createContact()1111     protected void createContact() {
1112         final List<AccountWithDataSet> accounts =
1113                 AccountTypeManager.getInstance(mContext).getAccounts(true);
1114         // No Accounts available. Create a phone-local contact.
1115         if (accounts.isEmpty()) {
1116             createContact(null);
1117             return;
1118         }
1119 
1120         // We have an account switcher in "create-account" screen, so don't need to ask a user to
1121         // select an account here.
1122         createContact(accounts.get(0));
1123     }
1124 
1125     /**
1126      * Shows account creation screen associated with a given account.
1127      *
1128      * @param account may be null to signal a device-local contact should be created.
1129      */
createContact(AccountWithDataSet account)1130     protected void createContact(AccountWithDataSet account) {
1131         final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
1132         final AccountType accountType = accountTypes.getAccountTypeForAccount(account);
1133 
1134         if (accountType.getCreateContactActivityClassName() != null) {
1135             if (mListener != null) {
1136                 mListener.onCustomCreateContactActivityRequested(account, mIntentExtras);
1137             }
1138         } else {
1139             setStateForNewContact(account, accountType, isEditingUserProfile());
1140         }
1141     }
1142 
1143     //
1144     // Data binding
1145     //
1146 
setState(Contact contact)1147     private void setState(Contact contact) {
1148         // If we have already loaded data, we do not want to change it here to not confuse the user
1149         if (!mState.isEmpty()) {
1150             Log.v(TAG, "Ignoring background change. This will have to be rebased later");
1151             return;
1152         }
1153 
1154         // See if this edit operation needs to be redirected to a custom editor
1155         mRawContacts = contact.getRawContacts();
1156         if (mRawContacts.size() == 1) {
1157             RawContact rawContact = mRawContacts.get(0);
1158             String type = rawContact.getAccountTypeString();
1159             String dataSet = rawContact.getDataSet();
1160             AccountType accountType = rawContact.getAccountType(mContext);
1161             if (accountType.getEditContactActivityClassName() != null &&
1162                     !accountType.areContactsWritable()) {
1163                 if (mListener != null) {
1164                     String name = rawContact.getAccountName();
1165                     long rawContactId = rawContact.getId();
1166                     mListener.onCustomEditContactActivityRequested(
1167                             new AccountWithDataSet(name, type, dataSet),
1168                             ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
1169                             mIntentExtras, true);
1170                 }
1171                 return;
1172             }
1173         }
1174 
1175         String readOnlyDisplayName = null;
1176         // Check for writable raw contacts.  If there are none, then we need to create one so user
1177         // can edit.  For the user profile case, there is already an editable contact.
1178         if (!contact.isUserProfile() && !contact.isWritableContact(mContext)) {
1179             mHasNewContact = true;
1180 
1181             // This is potentially an asynchronous call and will add deltas to list.
1182             selectAccountAndCreateContact();
1183 
1184             readOnlyDisplayName = contact.getDisplayName();
1185         } else {
1186             mHasNewContact = false;
1187         }
1188 
1189         // This also adds deltas to list.  If readOnlyDisplayName is null at this point it is
1190         // simply ignored later on by the editor.
1191         setStateForExistingContact(readOnlyDisplayName, contact.isUserProfile(), mRawContacts);
1192     }
1193 
1194     /**
1195      * Prepare {@link #mState} for a newly created phone-local contact.
1196      */
setStateForNewContact(AccountWithDataSet account, AccountType accountType, boolean isUserProfile)1197     private void setStateForNewContact(AccountWithDataSet account, AccountType accountType,
1198             boolean isUserProfile) {
1199         setStateForNewContact(account, accountType, /* oldState =*/ null,
1200                 /* oldAccountType =*/ null, isUserProfile);
1201     }
1202 
1203     /**
1204      * Prepare {@link #mState} for a newly created phone-local contact, migrating the state
1205      * specified by oldState and oldAccountType.
1206      */
setStateForNewContact(AccountWithDataSet account, AccountType accountType, RawContactDelta oldState, AccountType oldAccountType, boolean isUserProfile)1207     protected void setStateForNewContact(AccountWithDataSet account, AccountType accountType,
1208             RawContactDelta oldState, AccountType oldAccountType, boolean isUserProfile) {
1209         mStatus = Status.EDITING;
1210         mState.add(createNewRawContactDelta(account, accountType, oldState, oldAccountType));
1211         mIsUserProfile = isUserProfile;
1212         mNewContactDataReady = true;
1213         bindEditors();
1214     }
1215 
1216     /**
1217      * Returns a {@link RawContactDelta} for a new contact suitable for addition into
1218      * {@link #mState}.
1219      *
1220      * If oldState and oldAccountType are specified, the state specified by those parameters
1221      * is migrated to the result {@link RawContactDelta}.
1222      */
createNewRawContactDelta(AccountWithDataSet account, AccountType accountType, RawContactDelta oldState, AccountType oldAccountType)1223     private RawContactDelta createNewRawContactDelta(AccountWithDataSet account,
1224             AccountType accountType, RawContactDelta oldState, AccountType oldAccountType) {
1225         final RawContact rawContact = new RawContact();
1226         if (account != null) {
1227             rawContact.setAccount(account);
1228         } else {
1229             rawContact.setAccountToLocal();
1230         }
1231 
1232         final RawContactDelta result = new RawContactDelta(
1233                 ValuesDelta.fromAfter(rawContact.getValues()));
1234         if (oldState == null) {
1235             // Parse any values from incoming intent
1236             RawContactModifier.parseExtras(mContext, accountType, result, mIntentExtras);
1237         } else {
1238             RawContactModifier.migrateStateForNewContact(
1239                     mContext, oldState, result, oldAccountType, accountType);
1240         }
1241 
1242         // Ensure we have some default fields (if the account type does not support a field,
1243         // ensureKind will not add it, so it is safe to add e.g. Event)
1244         RawContactModifier.ensureKindExists(result, accountType, Phone.CONTENT_ITEM_TYPE);
1245         RawContactModifier.ensureKindExists(result, accountType, Email.CONTENT_ITEM_TYPE);
1246         RawContactModifier.ensureKindExists(result, accountType, Organization.CONTENT_ITEM_TYPE);
1247         RawContactModifier.ensureKindExists(result, accountType, Event.CONTENT_ITEM_TYPE);
1248         RawContactModifier.ensureKindExists(result, accountType,
1249                 StructuredPostal.CONTENT_ITEM_TYPE);
1250 
1251         // Set the correct URI for saving the contact as a profile
1252         if (mNewLocalProfile) {
1253             result.setProfileQueryUri();
1254         }
1255 
1256         return result;
1257     }
1258 
1259     /**
1260      * Prepare {@link #mState} for an existing contact.
1261      */
setStateForExistingContact(String readOnlyDisplayName, boolean isUserProfile, ImmutableList<RawContact> rawContacts)1262     protected void setStateForExistingContact(String readOnlyDisplayName, boolean isUserProfile,
1263             ImmutableList<RawContact> rawContacts) {
1264         setEnabled(true);
1265         mReadOnlyDisplayName = readOnlyDisplayName;
1266 
1267         mState.addAll(rawContacts.iterator());
1268         setIntentExtras(mIntentExtras);
1269         mIntentExtras = null;
1270 
1271         // For user profile, change the contacts query URI
1272         mIsUserProfile = isUserProfile;
1273         boolean localProfileExists = false;
1274 
1275         if (mIsUserProfile) {
1276             for (RawContactDelta rawContactDelta : mState) {
1277                 // For profile contacts, we need a different query URI
1278                 rawContactDelta.setProfileQueryUri();
1279                 // Try to find a local profile contact
1280                 if (rawContactDelta.getValues().getAsString(RawContacts.ACCOUNT_TYPE) == null) {
1281                     localProfileExists = true;
1282                 }
1283             }
1284             // Editor should always present a local profile for editing
1285             if (!localProfileExists) {
1286                 mState.add(createLocalRawContactDelta());
1287             }
1288         }
1289         mExistingContactDataReady = true;
1290         bindEditors();
1291     }
1292 
1293     /**
1294      * Returns a {@link RawContactDelta} for a local contact suitable for addition into
1295      * {@link #mState}.
1296      */
createLocalRawContactDelta()1297     private static RawContactDelta createLocalRawContactDelta() {
1298         final RawContact rawContact = new RawContact();
1299         rawContact.setAccountToLocal();
1300 
1301         final RawContactDelta result = new RawContactDelta(
1302                 ValuesDelta.fromAfter(rawContact.getValues()));
1303         result.setProfileQueryUri();
1304 
1305         return result;
1306     }
1307 
1308     /**
1309      * Sets group metadata on all bound editors.
1310      */
setGroupMetaData()1311     abstract protected void setGroupMetaData();
1312 
1313     /**
1314      * Bind editors using {@link #mState} and other members initialized from the loaded (or new)
1315      * Contact.
1316      */
bindEditors()1317     abstract protected void bindEditors();
1318 
1319     /**
1320      * Set the enabled state of editors.
1321      */
setEnabled(boolean enabled)1322     private void setEnabled(boolean enabled) {
1323         if (mEnabled != enabled) {
1324             mEnabled = enabled;
1325 
1326             // Enable/disable editors
1327             if (mContent != null) {
1328                 int count = mContent.getChildCount();
1329                 for (int i = 0; i < count; i++) {
1330                     mContent.getChildAt(i).setEnabled(enabled);
1331                 }
1332             }
1333 
1334             // Enable/disable aggregation suggestion vies
1335             if (mAggregationSuggestionView != null) {
1336                 LinearLayout itemList = (LinearLayout) mAggregationSuggestionView.findViewById(
1337                         R.id.aggregation_suggestions);
1338                 int count = itemList.getChildCount();
1339                 for (int i = 0; i < count; i++) {
1340                     itemList.getChildAt(i).setEnabled(enabled);
1341                 }
1342             }
1343 
1344             // Maybe invalidate the options menu
1345             final Activity activity = getActivity();
1346             if (activity != null) activity.invalidateOptionsMenu();
1347         }
1348     }
1349 
1350     /**
1351      * Removes a current editor ({@link #mState}) and rebinds new editor for a new account.
1352      * Some of old data are reused with new restriction enforced by the new account.
1353      *
1354      * @param oldState Old data being edited.
1355      * @param oldAccount Old account associated with oldState.
1356      * @param newAccount New account to be used.
1357      */
rebindEditorsForNewContact( RawContactDelta oldState, AccountWithDataSet oldAccount, AccountWithDataSet newAccount)1358     protected void rebindEditorsForNewContact(
1359             RawContactDelta oldState, AccountWithDataSet oldAccount,
1360             AccountWithDataSet newAccount) {
1361         AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
1362         AccountType oldAccountType = accountTypes.getAccountTypeForAccount(oldAccount);
1363         AccountType newAccountType = accountTypes.getAccountTypeForAccount(newAccount);
1364 
1365         if (newAccountType.getCreateContactActivityClassName() != null) {
1366             Log.w(TAG, "external activity called in rebind situation");
1367             if (mListener != null) {
1368                 mListener.onCustomCreateContactActivityRequested(newAccount, mIntentExtras);
1369             }
1370         } else {
1371             mExistingContactDataReady = false;
1372             mNewContactDataReady = false;
1373             mState = new RawContactDeltaList();
1374             setStateForNewContact(newAccount, newAccountType, oldState, oldAccountType,
1375                     isEditingUserProfile());
1376             if (mIsEdit) {
1377                 setStateForExistingContact(mReadOnlyDisplayName, isEditingUserProfile(),
1378                         mRawContacts);
1379             }
1380         }
1381     }
1382 
1383     //
1384     // ContactEditor
1385     //
1386 
1387     @Override
setListener(Listener listener)1388     public void setListener(Listener listener) {
1389         mListener = listener;
1390     }
1391 
1392     @Override
load(String action, Uri lookupUri, Bundle intentExtras)1393     public void load(String action, Uri lookupUri, Bundle intentExtras) {
1394         mAction = action;
1395         mLookupUri = lookupUri;
1396         mIntentExtras = intentExtras;
1397 
1398         if (mIntentExtras != null) {
1399             mAutoAddToDefaultGroup =
1400                     mIntentExtras.containsKey(INTENT_EXTRA_ADD_TO_DEFAULT_DIRECTORY);
1401             mNewLocalProfile =
1402                     mIntentExtras.getBoolean(INTENT_EXTRA_NEW_LOCAL_PROFILE);
1403             mDisableDeleteMenuOption =
1404                     mIntentExtras.getBoolean(INTENT_EXTRA_DISABLE_DELETE_MENU_OPTION);
1405             if (mIntentExtras.containsKey(INTENT_EXTRA_MATERIAL_PALETTE_PRIMARY_COLOR)
1406                     && mIntentExtras.containsKey(INTENT_EXTRA_MATERIAL_PALETTE_SECONDARY_COLOR)) {
1407                 mMaterialPalette = new MaterialColorMapUtils.MaterialPalette(
1408                         mIntentExtras.getInt(INTENT_EXTRA_MATERIAL_PALETTE_PRIMARY_COLOR),
1409                         mIntentExtras.getInt(INTENT_EXTRA_MATERIAL_PALETTE_SECONDARY_COLOR));
1410             }
1411             // If the user selected a different photo, don't restore the one from the Intent
1412             if (mPhotoId < 0) {
1413                 mPhotoId = mIntentExtras.getLong(INTENT_EXTRA_PHOTO_ID);
1414             }
1415             mRawContactIdToDisplayAlone = mIntentExtras.getLong(
1416                     INTENT_EXTRA_RAW_CONTACT_ID_TO_DISPLAY_ALONE, -1);
1417             mRawContactDisplayAloneIsReadOnly = mIntentExtras.getBoolean(
1418                     INTENT_EXTRA_RAW_CONTACT_DISPLAY_ALONE_IS_READ_ONLY);
1419         }
1420     }
1421 
1422     @Override
setIntentExtras(Bundle extras)1423     public void setIntentExtras(Bundle extras) {
1424         if (extras == null || extras.size() == 0) {
1425             return;
1426         }
1427 
1428         final AccountTypeManager accountTypes = AccountTypeManager.getInstance(mContext);
1429         for (RawContactDelta state : mState) {
1430             final AccountType type = state.getAccountType(accountTypes);
1431             if (type.areContactsWritable()) {
1432                 // Apply extras to the first writable raw contact only
1433                 RawContactModifier.parseExtras(mContext, type, state, extras);
1434                 break;
1435             }
1436         }
1437     }
1438 
1439     @Override
onJoinCompleted(Uri uri)1440     public void onJoinCompleted(Uri uri) {
1441         onSaveCompleted(false, SaveMode.RELOAD, uri != null, uri, /* joinContactId */ null);
1442     }
1443 
1444     @Override
onSaveCompleted(boolean hadChanges, int saveMode, boolean saveSucceeded, Uri contactLookupUri, Long joinContactId)1445     public void onSaveCompleted(boolean hadChanges, int saveMode, boolean saveSucceeded,
1446             Uri contactLookupUri, Long joinContactId) {
1447         if (hadChanges) {
1448             if (saveSucceeded) {
1449                 switch (saveMode) {
1450                     case SaveMode.JOIN:
1451                         break;
1452                     case SaveMode.SPLIT:
1453                         Toast.makeText(mContext, R.string.contactUnlinkedToast, Toast.LENGTH_SHORT)
1454                                 .show();
1455                         break;
1456                     default:
1457                         Toast.makeText(mContext, R.string.contactSavedToast, Toast.LENGTH_SHORT)
1458                                 .show();
1459                 }
1460 
1461             } else {
1462                 Toast.makeText(mContext, R.string.contactSavedErrorToast, Toast.LENGTH_LONG).show();
1463             }
1464         }
1465         switch (saveMode) {
1466             case SaveMode.CLOSE: {
1467                 final Intent resultIntent;
1468                 if (saveSucceeded && contactLookupUri != null) {
1469                     final Uri lookupUri = maybeConvertToLegacyLookupUri(
1470                             mContext, contactLookupUri, mLookupUri);
1471                     resultIntent = ImplicitIntentsUtil.composeQuickContactIntent(lookupUri,
1472                             QuickContactActivity.MODE_FULLY_EXPANDED);
1473                     resultIntent.putExtra(QuickContactActivity.EXTRA_PREVIOUS_SCREEN_TYPE,
1474                             ScreenType.EDITOR);
1475                 } else {
1476                     resultIntent = null;
1477                 }
1478                 // It is already saved, so prevent it from being saved again
1479                 mStatus = Status.CLOSING;
1480                 if (mListener != null) mListener.onSaveFinished(resultIntent);
1481                 break;
1482             }
1483             case SaveMode.COMPACT: {
1484                 // It is already saved, so prevent it from being saved again
1485                 mStatus = Status.CLOSING;
1486                 if (mListener != null) mListener.onSaveFinished(/* resultIntent= */ null);
1487                 break;
1488             }
1489             case SaveMode.JOIN:
1490                 if (saveSucceeded && contactLookupUri != null && joinContactId != null) {
1491                     joinAggregate(joinContactId);
1492                 }
1493                 break;
1494             case SaveMode.RELOAD:
1495                 if (saveSucceeded && contactLookupUri != null) {
1496                     // If this was in INSERT, we are changing into an EDIT now.
1497                     // If it already was an EDIT, we are changing to the new Uri now
1498                     mState = new RawContactDeltaList();
1499                     load(Intent.ACTION_EDIT, contactLookupUri, null);
1500                     mStatus = Status.LOADING;
1501                     getLoaderManager().restartLoader(LOADER_CONTACT, null, mContactLoaderListener);
1502                 }
1503                 break;
1504 
1505             case SaveMode.SPLIT:
1506                 mStatus = Status.CLOSING;
1507                 if (mListener != null) {
1508                     mListener.onContactSplit(contactLookupUri);
1509                 } else {
1510                     Log.d(TAG, "No listener registered, can not call onSplitFinished");
1511                 }
1512                 break;
1513         }
1514     }
1515 
1516     /**
1517      * Shows a list of aggregates that can be joined into the currently viewed aggregate.
1518      *
1519      * @param contactLookupUri the fresh URI for the currently edited contact (after saving it)
1520      */
showJoinAggregateActivity(Uri contactLookupUri)1521     private void showJoinAggregateActivity(Uri contactLookupUri) {
1522         if (contactLookupUri == null || !isAdded()) {
1523             return;
1524         }
1525 
1526         mContactIdForJoin = ContentUris.parseId(contactLookupUri);
1527         final Intent intent = new Intent(UiIntentActions.PICK_JOIN_CONTACT_ACTION);
1528         intent.putExtra(UiIntentActions.TARGET_CONTACT_ID_EXTRA_KEY, mContactIdForJoin);
1529         startActivityForResult(intent, REQUEST_CODE_JOIN);
1530     }
1531 
1532     //
1533     // Aggregation PopupWindow
1534     //
1535 
1536     /**
1537      * Triggers an asynchronous search for aggregation suggestions.
1538      */
acquireAggregationSuggestions(Context context, long rawContactId, ValuesDelta valuesDelta)1539     protected void acquireAggregationSuggestions(Context context,
1540             long rawContactId, ValuesDelta valuesDelta) {
1541         if (mAggregationSuggestionsRawContactId != rawContactId
1542                 && mAggregationSuggestionView != null) {
1543             mAggregationSuggestionView.setVisibility(View.GONE);
1544             mAggregationSuggestionView = null;
1545             mAggregationSuggestionEngine.reset();
1546         }
1547 
1548         mAggregationSuggestionsRawContactId = rawContactId;
1549 
1550         if (mAggregationSuggestionEngine == null) {
1551             mAggregationSuggestionEngine = new AggregationSuggestionEngine(context);
1552             mAggregationSuggestionEngine.setListener(this);
1553             mAggregationSuggestionEngine.start();
1554         }
1555 
1556         mAggregationSuggestionEngine.setContactId(getContactId());
1557 
1558         mAggregationSuggestionEngine.onNameChange(valuesDelta);
1559     }
1560 
1561     /**
1562      * Returns the contact ID for the currently edited contact or 0 if the contact is new.
1563      */
getContactId()1564     private long getContactId() {
1565         for (RawContactDelta rawContact : mState) {
1566             Long contactId = rawContact.getValues().getAsLong(RawContacts.CONTACT_ID);
1567             if (contactId != null) {
1568                 return contactId;
1569             }
1570         }
1571         return 0;
1572     }
1573 
1574     @Override
onAggregationSuggestionChange()1575     public void onAggregationSuggestionChange() {
1576         final Activity activity = getActivity();
1577         if ((activity != null && activity.isFinishing())
1578                 || !isVisible() ||  mState.isEmpty() || mStatus != Status.EDITING) {
1579             return;
1580         }
1581 
1582         UiClosables.closeQuietly(mAggregationSuggestionPopup);
1583 
1584         if (mAggregationSuggestionEngine.getSuggestedContactCount() == 0) {
1585             return;
1586         }
1587 
1588         final View anchorView = getAggregationAnchorView(mAggregationSuggestionsRawContactId);
1589         if (anchorView == null) {
1590             return; // Raw contact deleted?
1591         }
1592         mAggregationSuggestionPopup = new ListPopupWindow(mContext, null);
1593         mAggregationSuggestionPopup.setAnchorView(anchorView);
1594         mAggregationSuggestionPopup.setWidth(anchorView.getWidth());
1595         mAggregationSuggestionPopup.setInputMethodMode(ListPopupWindow.INPUT_METHOD_NOT_NEEDED);
1596         mAggregationSuggestionPopup.setAdapter(
1597                 new AggregationSuggestionAdapter(
1598                         getActivity(),
1599                         mState.size() == 1 && mState.get(0).isContactInsert(),
1600                         /* listener =*/ this,
1601                         mAggregationSuggestionEngine.getSuggestions()));
1602         mAggregationSuggestionPopup.setOnItemClickListener(new AdapterView.OnItemClickListener() {
1603             @Override
1604             public void onItemClick(AdapterView<?> parent, View view, int position, long id) {
1605                 final AggregationSuggestionView suggestionView = (AggregationSuggestionView) view;
1606                 suggestionView.handleItemClickEvent();
1607                 UiClosables.closeQuietly(mAggregationSuggestionPopup);
1608                 mAggregationSuggestionPopup = null;
1609             }
1610         });
1611         mAggregationSuggestionPopup.show();
1612     }
1613 
1614     /**
1615      * Returns the raw contact editor view for the given rawContactId that should be used as the
1616      * anchor for aggregation suggestions.
1617      */
getAggregationAnchorView(long rawContactId)1618     abstract protected View getAggregationAnchorView(long rawContactId);
1619 
1620     /**
1621      * Whether the given raw contact ID matches the one used to last load aggregation
1622      * suggestions.
1623      */
isAggregationSuggestionRawContactId(long rawContactId)1624     protected boolean isAggregationSuggestionRawContactId(long rawContactId) {
1625         return mAggregationSuggestionsRawContactId == rawContactId;
1626     }
1627 
1628     @Override
onJoinAction(long contactId, List<Long> rawContactIdList)1629     public void onJoinAction(long contactId, List<Long> rawContactIdList) {
1630         final long rawContactIds[] = new long[rawContactIdList.size()];
1631         for (int i = 0; i < rawContactIds.length; i++) {
1632             rawContactIds[i] = rawContactIdList.get(i);
1633         }
1634         try {
1635             JoinSuggestedContactDialogFragment.show(this, rawContactIds);
1636         } catch (Exception ignored) {
1637             // No problem - the activity is no longer available to display the dialog
1638         }
1639     }
1640 
1641     /**
1642      * Joins the suggested contact (specified by the id's of constituent raw
1643      * contacts), save all changes, and stay in the editor.
1644      */
doJoinSuggestedContact(long[] rawContactIds)1645     protected void doJoinSuggestedContact(long[] rawContactIds) {
1646         if (!hasValidState() || mStatus != Status.EDITING) {
1647             return;
1648         }
1649 
1650         mState.setJoinWithRawContacts(rawContactIds);
1651         save(SaveMode.RELOAD);
1652     }
1653 
1654     @Override
onEditAction(Uri contactLookupUri)1655     public void onEditAction(Uri contactLookupUri) {
1656         SuggestionEditConfirmationDialogFragment.show(this, contactLookupUri);
1657     }
1658 
1659     /**
1660      * Abandons the currently edited contact and switches to editing the suggested
1661      * one, transferring all the data there
1662      */
doEditSuggestedContact(Uri contactUri)1663     protected void doEditSuggestedContact(Uri contactUri) {
1664         if (mListener != null) {
1665             // make sure we don't save this contact when closing down
1666             mStatus = Status.CLOSING;
1667             mListener.onEditOtherContactRequested(
1668                     contactUri, mState.get(0).getContentValues());
1669         }
1670     }
1671 
1672     //
1673     // Join Activity
1674     //
1675 
1676     /**
1677      * Performs aggregation with the contact selected by the user from suggestions or A-Z list.
1678      */
joinAggregate(long contactId)1679     abstract protected void joinAggregate(long contactId);
1680 
1681     //
1682     // Utility methods
1683     //
1684 
1685     /**
1686      * Returns a legacy version of the given contactLookupUri if a legacy Uri was originally
1687      * passed to the contact editor.
1688      *
1689      * @param contactLookupUri The Uri to possibly convert to legacy format.
1690      * @param requestLookupUri The lookup Uri originally passed to the contact editor
1691      *                         (via Intent data), may be null.
1692      */
maybeConvertToLegacyLookupUri(Context context, Uri contactLookupUri, Uri requestLookupUri)1693     protected static Uri maybeConvertToLegacyLookupUri(Context context, Uri contactLookupUri,
1694             Uri requestLookupUri) {
1695         final String legacyAuthority = "contacts";
1696         final String requestAuthority = requestLookupUri == null
1697                 ? null : requestLookupUri.getAuthority();
1698         if (legacyAuthority.equals(requestAuthority)) {
1699             // Build a legacy Uri if that is what was requested by caller
1700             final long contactId = ContentUris.parseId(Contacts.lookupContact(
1701                     context.getContentResolver(), contactLookupUri));
1702             final Uri legacyContentUri = Uri.parse("content://contacts/people");
1703             return ContentUris.withAppendedId(legacyContentUri, contactId);
1704         }
1705         // Otherwise pass back a lookup-style Uri
1706         return contactLookupUri;
1707     }
1708 
1709     /**
1710      * Whether the argument Intent requested a contact insert action or not.
1711      */
isInsert(Intent intent)1712     protected static boolean isInsert(Intent intent) {
1713         return intent == null ? false : isInsert(intent.getAction());
1714     }
1715 
isInsert(String action)1716     protected static boolean isInsert(String action) {
1717         return Intent.ACTION_INSERT.equals(action)
1718                 || ContactEditorBaseActivity.ACTION_INSERT.equals(action);
1719     }
1720 
isEdit(String action)1721     protected static boolean isEdit(String action) {
1722         return Intent.ACTION_EDIT.equals(action)
1723                 || ContactEditorBaseActivity.ACTION_EDIT.equals(action);
1724     }
1725 }
1726