2 * Copyright (C) 2009 The Android Open Source Project
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
8 * http://www.apache.org/licenses/LICENSE-2.0
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.
17 package com.android.contacts.ui;
19 import com.android.contacts.ContactsListActivity;
20 import com.android.contacts.ContactsSearchManager;
21 import com.android.contacts.ContactsUtils;
22 import com.android.contacts.R;
23 import com.android.contacts.model.ContactsSource;
24 import com.android.contacts.model.Editor;
25 import com.android.contacts.model.EntityDelta;
26 import com.android.contacts.model.EntityModifier;
27 import com.android.contacts.model.EntitySet;
28 import com.android.contacts.model.GoogleSource;
29 import com.android.contacts.model.Sources;
30 import com.android.contacts.model.ContactsSource.EditType;
31 import com.android.contacts.model.Editor.EditorListener;
32 import com.android.contacts.model.EntityDelta.ValuesDelta;
33 import com.android.contacts.ui.widget.BaseContactEditorView;
34 import com.android.contacts.ui.widget.PhotoEditorView;
35 import com.android.contacts.util.EmptyService;
36 import com.android.contacts.util.WeakAsyncTask;
37 import com.google.android.collect.Lists;
39 import android.accounts.Account;
40 import android.app.Activity;
41 import android.app.AlertDialog;
42 import android.app.Dialog;
43 import android.app.ProgressDialog;
44 import android.content.ActivityNotFoundException;
45 import android.content.ContentProviderOperation;
46 import android.content.ContentProviderResult;
47 import android.content.ContentResolver;
48 import android.content.ContentUris;
49 import android.content.ContentValues;
50 import android.content.Context;
51 import android.content.DialogInterface;
52 import android.content.Entity;
53 import android.content.Intent;
54 import android.content.OperationApplicationException;
55 import android.content.ContentProviderOperation.Builder;
56 import android.database.Cursor;
57 import android.graphics.Bitmap;
58 import android.media.MediaScannerConnection;
59 import android.net.Uri;
60 import android.os.Bundle;
61 import android.os.Environment;
62 import android.os.RemoteException;
63 import android.provider.ContactsContract;
64 import android.provider.MediaStore;
65 import android.provider.ContactsContract.AggregationExceptions;
66 import android.provider.ContactsContract.Contacts;
67 import android.provider.ContactsContract.RawContacts;
68 import android.provider.ContactsContract.CommonDataKinds.Email;
69 import android.provider.ContactsContract.CommonDataKinds.Phone;
70 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
71 import android.provider.ContactsContract.Contacts.Data;
72 import android.util.Log;
73 import android.view.ContextThemeWrapper;
74 import android.view.LayoutInflater;
75 import android.view.Menu;
76 import android.view.MenuInflater;
77 import android.view.MenuItem;
78 import android.view.View;
79 import android.view.ViewGroup;
80 import android.widget.ArrayAdapter;
81 import android.widget.LinearLayout;
82 import android.widget.ListAdapter;
83 import android.widget.TextView;
84 import android.widget.Toast;
87 import java.lang.ref.WeakReference;
88 import java.text.SimpleDateFormat;
89 import java.util.ArrayList;
90 import java.util.Collections;
91 import java.util.Comparator;
92 import java.util.Date;
95 * Activity for editing or inserting a contact.
97 public final class EditContactActivity extends Activity
98 implements View.OnClickListener, Comparator<EntityDelta> {
100 private static final String TAG = "EditContactActivity";
102 /** The launch code when picking a photo and the raw data is returned */
103 private static final int PHOTO_PICKED_WITH_DATA = 3021;
105 /** The launch code when a contact to join with is returned */
106 private static final int REQUEST_JOIN_CONTACT = 3022;
108 /** The launch code when taking a picture */
109 private static final int CAMERA_WITH_DATA = 3023;
111 private static final String KEY_EDIT_STATE = "state";
112 private static final String KEY_RAW_CONTACT_ID_REQUESTING_PHOTO = "photorequester";
113 private static final String KEY_VIEW_ID_GENERATOR = "viewidgenerator";
115 /** The result code when view activity should close after edit returns */
116 public static final int RESULT_CLOSE_VIEW_ACTIVITY = 777;
118 public static final int SAVE_MODE_DEFAULT = 0;
119 public static final int SAVE_MODE_SPLIT = 1;
120 public static final int SAVE_MODE_JOIN = 2;
122 private long mRawContactIdRequestingPhoto = -1;
124 private static final int DIALOG_CONFIRM_DELETE = 1;
125 private static final int DIALOG_CONFIRM_READONLY_DELETE = 2;
126 private static final int DIALOG_CONFIRM_MULTIPLE_DELETE = 3;
127 private static final int DIALOG_CONFIRM_READONLY_HIDE = 4;
129 private static final int ICON_SIZE = 96;
131 private static final File PHOTO_DIR = new File(Environment.getExternalStorageDirectory(),
132 "com.android.contacts.icon");
134 private File mCurrentPhotoFile;
136 String mQuerySelection;
138 private long mContactIdForJoin;
140 private static final int STATUS_LOADING = 0;
141 private static final int STATUS_EDITING = 1;
142 private static final int STATUS_SAVING = 2;
148 /** The linear layout holding the ContactEditorViews */
149 LinearLayout mContent;
151 private ArrayList<Dialog> mManagedDialogs = Lists.newArrayList();
153 private ViewIdGenerator mViewIdGenerator;
156 protected void onCreate(Bundle icicle) {
157 super.onCreate(icicle);
159 final Intent intent = getIntent();
160 final String action = intent.getAction();
162 setContentView(R.layout.act_edit);
164 // Build editor and listen for photo requests
165 mContent = (LinearLayout) findViewById(R.id.editors);
167 findViewById(R.id.btn_done).setOnClickListener(this);
168 findViewById(R.id.btn_discard).setOnClickListener(this);
170 // Handle initial actions only when existing state missing
171 final boolean hasIncomingState = icicle != null && icicle.containsKey(KEY_EDIT_STATE);
173 if (Intent.ACTION_EDIT.equals(action) && !hasIncomingState) {
174 setTitle(R.string.editContact_title_edit);
175 mStatus = STATUS_LOADING;
177 // Read initial state from database
178 new QueryEntitiesTask(this).execute(intent);
179 } else if (Intent.ACTION_INSERT.equals(action) && !hasIncomingState) {
180 setTitle(R.string.editContact_title_insert);
181 mStatus = STATUS_EDITING;
182 // Trigger dialog to pick account type
186 if (icicle == null) {
187 // If icicle is non-null, onRestoreInstanceState() will restore the generator.
188 mViewIdGenerator = new ViewIdGenerator();
192 private static class QueryEntitiesTask extends
193 WeakAsyncTask<Intent, Void, EntitySet, EditContactActivity> {
195 private String mSelection;
197 public QueryEntitiesTask(EditContactActivity target) {
202 protected EntitySet doInBackground(EditContactActivity target, Intent... params) {
203 final Intent intent = params[0];
205 final ContentResolver resolver = target.getContentResolver();
207 // Handle both legacy and new authorities
208 final Uri data = intent.getData();
209 final String authority = data.getAuthority();
210 final String mimeType = intent.resolveType(resolver);
213 if (ContactsContract.AUTHORITY.equals(authority)) {
214 if (Contacts.CONTENT_ITEM_TYPE.equals(mimeType)) {
215 // Handle selected aggregate
216 final long contactId = ContentUris.parseId(data);
217 mSelection = RawContacts.CONTACT_ID + "=" + contactId;
218 } else if (RawContacts.CONTENT_ITEM_TYPE.equals(mimeType)) {
219 final long rawContactId = ContentUris.parseId(data);
220 final long contactId = ContactsUtils.queryForContactId(resolver, rawContactId);
221 mSelection = RawContacts.CONTACT_ID + "=" + contactId;
223 } else if (android.provider.Contacts.AUTHORITY.equals(authority)) {
224 final long rawContactId = ContentUris.parseId(data);
225 mSelection = Data.RAW_CONTACT_ID + "=" + rawContactId;
228 return EntitySet.fromQuery(target.getContentResolver(), mSelection, null, null);
232 protected void onPostExecute(EditContactActivity target, EntitySet entitySet) {
233 target.mQuerySelection = mSelection;
235 // Load edit details in background
236 final Context context = target;
237 final Sources sources = Sources.getInstance(context);
239 // Handle any incoming values that should be inserted
240 final Bundle extras = target.getIntent().getExtras();
241 final boolean hasExtras = extras != null && extras.size() > 0;
242 final boolean hasState = entitySet.size() > 0;
243 if (hasExtras && hasState) {
244 // Find source defining the first RawContact found
245 final EntityDelta state = entitySet.get(0);
246 final String accountType = state.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
247 final ContactsSource source = sources.getInflatedSource(accountType,
248 ContactsSource.LEVEL_CONSTRAINTS);
249 EntityModifier.parseExtras(context, source, state, extras);
252 target.mState = entitySet;
254 // Bind UI to new background state
255 target.bindEditors();
260 protected void onSaveInstanceState(Bundle outState) {
261 if (hasValidState()) {
262 // Store entities with modifications
263 outState.putParcelable(KEY_EDIT_STATE, mState);
266 outState.putLong(KEY_RAW_CONTACT_ID_REQUESTING_PHOTO, mRawContactIdRequestingPhoto);
267 outState.putParcelable(KEY_VIEW_ID_GENERATOR, mViewIdGenerator);
268 super.onSaveInstanceState(outState);
272 protected void onRestoreInstanceState(Bundle savedInstanceState) {
273 // Read modifications from instance
274 mState = savedInstanceState.<EntitySet> getParcelable(KEY_EDIT_STATE);
275 mRawContactIdRequestingPhoto = savedInstanceState.getLong(
276 KEY_RAW_CONTACT_ID_REQUESTING_PHOTO);
277 mViewIdGenerator = savedInstanceState.getParcelable(KEY_VIEW_ID_GENERATOR);
280 super.onRestoreInstanceState(savedInstanceState);
284 protected void onDestroy() {
287 for (Dialog dialog : mManagedDialogs) {
288 dismissDialog(dialog);
293 protected Dialog onCreateDialog(int id, Bundle bundle) {
295 case DIALOG_CONFIRM_DELETE:
296 return new AlertDialog.Builder(this)
297 .setTitle(R.string.deleteConfirmation_title)
298 .setIcon(android.R.drawable.ic_dialog_alert)
299 .setMessage(R.string.deleteConfirmation)
300 .setNegativeButton(android.R.string.cancel, null)
301 .setPositiveButton(android.R.string.ok, new DeleteClickListener())
302 .setCancelable(false)
304 case DIALOG_CONFIRM_READONLY_DELETE:
305 return new AlertDialog.Builder(this)
306 .setTitle(R.string.deleteConfirmation_title)
307 .setIcon(android.R.drawable.ic_dialog_alert)
308 .setMessage(R.string.readOnlyContactDeleteConfirmation)
309 .setNegativeButton(android.R.string.cancel, null)
310 .setPositiveButton(android.R.string.ok, new DeleteClickListener())
311 .setCancelable(false)
313 case DIALOG_CONFIRM_MULTIPLE_DELETE:
314 return new AlertDialog.Builder(this)
315 .setTitle(R.string.deleteConfirmation_title)
316 .setIcon(android.R.drawable.ic_dialog_alert)
317 .setMessage(R.string.multipleContactDeleteConfirmation)
318 .setNegativeButton(android.R.string.cancel, null)
319 .setPositiveButton(android.R.string.ok, new DeleteClickListener())
320 .setCancelable(false)
322 case DIALOG_CONFIRM_READONLY_HIDE:
323 return new AlertDialog.Builder(this)
324 .setTitle(R.string.deleteConfirmation_title)
325 .setIcon(android.R.drawable.ic_dialog_alert)
326 .setMessage(R.string.readOnlyContactWarning)
327 .setPositiveButton(android.R.string.ok, new DeleteClickListener())
328 .setCancelable(false)
335 * Start managing this {@link Dialog} along with the {@link Activity}.
337 private void startManagingDialog(Dialog dialog) {
338 synchronized (mManagedDialogs) {
339 mManagedDialogs.add(dialog);
344 * Show this {@link Dialog} and manage with the {@link Activity}.
346 void showAndManageDialog(Dialog dialog) {
347 startManagingDialog(dialog);
352 * Dismiss the given {@link Dialog}.
354 static void dismissDialog(Dialog dialog) {
356 // Only dismiss when valid reference and still showing
357 if (dialog != null && dialog.isShowing()) {
360 } catch (Exception e) {
361 Log.w(TAG, "Ignoring exception while dismissing dialog: " + e.toString());
366 * Check if our internal {@link #mState} is valid, usually checked before
367 * performing user actions.
369 protected boolean hasValidState() {
370 return mStatus == STATUS_EDITING && mState != null && mState.size() > 0;
374 * Rebuild the editors to match our underlying {@link #mState} object, usually
375 * called once we've parsed {@link Entity} data or have inserted a new
376 * {@link RawContacts}.
378 protected void bindEditors() {
379 if (mState == null) {
383 final LayoutInflater inflater = (LayoutInflater) getSystemService(
384 Context.LAYOUT_INFLATER_SERVICE);
385 final Sources sources = Sources.getInstance(this);
388 Collections.sort(mState, this);
390 // Remove any existing editors and rebuild any visible
391 mContent.removeAllViews();
392 int size = mState.size();
393 for (int i = 0; i < size; i++) {
394 // TODO ensure proper ordering of entities in the list
395 EntityDelta entity = mState.get(i);
396 final ValuesDelta values = entity.getValues();
397 if (!values.isVisible()) continue;
399 final String accountType = values.getAsString(RawContacts.ACCOUNT_TYPE);
400 final ContactsSource source = sources.getInflatedSource(accountType,
401 ContactsSource.LEVEL_CONSTRAINTS);
402 final long rawContactId = values.getAsLong(RawContacts._ID);
404 BaseContactEditorView editor;
405 if (!source.readOnly) {
406 editor = (BaseContactEditorView) inflater.inflate(R.layout.item_contact_editor,
409 editor = (BaseContactEditorView) inflater.inflate(
410 R.layout.item_read_only_contact_editor, mContent, false);
412 PhotoEditorView photoEditor = editor.getPhotoEditor();
413 photoEditor.setEditorListener(new PhotoListener(rawContactId, source.readOnly,
416 mContent.addView(editor);
417 editor.setState(entity, source, mViewIdGenerator);
420 // Show editor now that we've loaded state
421 mContent.setVisibility(View.VISIBLE);
422 mStatus = STATUS_EDITING;
426 * Class that listens to requests coming from photo editors
428 private class PhotoListener implements EditorListener, DialogInterface.OnClickListener {
429 private long mRawContactId;
430 private boolean mReadOnly;
431 private PhotoEditorView mEditor;
433 public PhotoListener(long rawContactId, boolean readOnly, PhotoEditorView editor) {
434 mRawContactId = rawContactId;
435 mReadOnly = readOnly;
439 public void onDeleted(Editor editor) {
443 public void onRequest(int request) {
444 if (!hasValidState()) return;
446 if (request == EditorListener.REQUEST_PICK_PHOTO) {
447 if (mEditor.hasSetPhoto()) {
448 // There is an existing photo, offer to remove, replace, or promoto to primary
449 createPhotoDialog().show();
450 } else if (!mReadOnly) {
451 // No photo set and not read-only, try to set the photo
452 doPickPhotoAction(mRawContactId);
458 * Prepare dialog for picking a new {@link EditType} or entering a
459 * custom label. This dialog is limited to the valid types as determined
460 * by {@link EntityModifier}.
462 public Dialog createPhotoDialog() {
463 Context context = EditContactActivity.this;
465 // Wrap our context to inflate list items using correct theme
466 final Context dialogContext = new ContextThemeWrapper(context,
467 android.R.style.Theme_Light);
471 choices = new String[1];
472 choices[0] = getString(R.string.use_photo_as_primary);
474 choices = new String[3];
475 choices[0] = getString(R.string.use_photo_as_primary);
476 choices[1] = getString(R.string.removePicture);
477 choices[2] = getString(R.string.changePicture);
479 final ListAdapter adapter = new ArrayAdapter<String>(dialogContext,
480 android.R.layout.simple_list_item_1, choices);
482 final AlertDialog.Builder builder = new AlertDialog.Builder(dialogContext);
483 builder.setTitle(R.string.attachToContact);
484 builder.setSingleChoiceItems(adapter, -1, this);
485 return builder.create();
489 * Called when something in the dialog is clicked
491 public void onClick(DialogInterface dialog, int which) {
496 // Set the photo as super primary
497 mEditor.setSuperPrimary(true);
499 // And set all other photos as not super primary
500 int count = mContent.getChildCount();
501 for (int i = 0; i < count; i++) {
502 View childView = mContent.getChildAt(i);
503 if (childView instanceof BaseContactEditorView) {
504 BaseContactEditorView editor = (BaseContactEditorView) childView;
505 PhotoEditorView photoEditor = editor.getPhotoEditor();
506 if (!photoEditor.equals(mEditor)) {
507 photoEditor.setSuperPrimary(false);
515 mEditor.setPhotoBitmap(null);
519 // Pick a new photo for the contact
520 doPickPhotoAction(mRawContactId);
527 public void onClick(View view) {
528 switch (view.getId()) {
530 doSaveAction(SAVE_MODE_DEFAULT);
532 case R.id.btn_discard:
540 public void onBackPressed() {
541 doSaveAction(SAVE_MODE_DEFAULT);
546 protected void onActivityResult(int requestCode, int resultCode, Intent data) {
547 // Ignore failed requests
548 if (resultCode != RESULT_OK) return;
550 switch (requestCode) {
551 case PHOTO_PICKED_WITH_DATA: {
552 BaseContactEditorView requestingEditor = null;
553 for (int i = 0; i < mContent.getChildCount(); i++) {
554 View childView = mContent.getChildAt(i);
555 if (childView instanceof BaseContactEditorView) {
556 BaseContactEditorView editor = (BaseContactEditorView) childView;
557 if (editor.getRawContactId() == mRawContactIdRequestingPhoto) {
558 requestingEditor = editor;
564 if (requestingEditor != null) {
565 final Bitmap photo = data.getParcelableExtra("data");
566 requestingEditor.setPhotoBitmap(photo);
567 mRawContactIdRequestingPhoto = -1;
569 // The contact that requested the photo is no longer present.
570 // TODO: Show error message
576 case CAMERA_WITH_DATA: {
577 doCropPhoto(mCurrentPhotoFile);
581 case REQUEST_JOIN_CONTACT: {
582 if (resultCode == RESULT_OK && data != null) {
583 final long contactId = ContentUris.parseId(data.getData());
584 joinAggregate(contactId);
591 public boolean onCreateOptionsMenu(Menu menu) {
592 super.onCreateOptionsMenu(menu);
594 MenuInflater inflater = getMenuInflater();
595 inflater.inflate(R.menu.edit, menu);
602 public boolean onPrepareOptionsMenu(Menu menu) {
603 menu.findItem(R.id.menu_split).setVisible(mState != null && mState.size() > 1);
608 public boolean onOptionsItemSelected(MenuItem item) {
609 switch (item.getItemId()) {
611 return doSaveAction(SAVE_MODE_DEFAULT);
612 case R.id.menu_discard:
613 return doRevertAction();
615 return doAddAction();
616 case R.id.menu_delete:
617 return doDeleteAction();
618 case R.id.menu_split:
619 return doSplitContactAction();
621 return doJoinContactAction();
627 * Background task for persisting edited contact data, using the changes
628 * defined by a set of {@link EntityDelta}. This task starts
629 * {@link EmptyService} to make sure the background thread can finish
630 * persisting in cases where the system wants to reclaim our process.
632 public static class PersistTask extends
633 WeakAsyncTask<EntitySet, Void, Integer, EditContactActivity> {
634 private static final int PERSIST_TRIES = 3;
636 private static final int RESULT_UNCHANGED = 0;
637 private static final int RESULT_SUCCESS = 1;
638 private static final int RESULT_FAILURE = 2;
640 private WeakReference<ProgressDialog> mProgress;
642 private int mSaveMode;
643 private Uri mContactLookupUri = null;
645 public PersistTask(EditContactActivity target, int saveMode) {
647 mSaveMode = saveMode;
652 protected void onPreExecute(EditContactActivity target) {
653 mProgress = new WeakReference<ProgressDialog>(ProgressDialog.show(target, null,
654 target.getText(R.string.savingContact)));
656 // Before starting this task, start an empty service to protect our
657 // process from being reclaimed by the system.
658 final Context context = target;
659 context.startService(new Intent(context, EmptyService.class));
664 protected Integer doInBackground(EditContactActivity target, EntitySet... params) {
665 final Context context = target;
666 final ContentResolver resolver = context.getContentResolver();
668 EntitySet state = params[0];
670 // Trim any empty fields, and RawContacts, before persisting
671 final Sources sources = Sources.getInstance(context);
672 EntityModifier.trimEmpty(state, sources);
674 // Attempt to persist changes
676 Integer result = RESULT_FAILURE;
677 while (tries++ < PERSIST_TRIES) {
679 // Build operations and try applying
680 final ArrayList<ContentProviderOperation> diff = state.buildDiff();
681 ContentProviderResult[] results = null;
682 if (!diff.isEmpty()) {
683 results = resolver.applyBatch(ContactsContract.AUTHORITY, diff);
686 final long rawContactId = getRawContactId(state, diff, results);
687 if (rawContactId != -1) {
688 final Uri rawContactUri = ContentUris.withAppendedId(
689 RawContacts.CONTENT_URI, rawContactId);
691 // convert the raw contact URI to a contact URI
692 mContactLookupUri = RawContacts.getContactLookupUri(resolver,
695 result = (diff.size() > 0) ? RESULT_SUCCESS : RESULT_UNCHANGED;
698 } catch (RemoteException e) {
699 // Something went wrong, bail without success
700 Log.e(TAG, "Problem persisting user edits", e);
703 } catch (OperationApplicationException e) {
704 // Version consistency failed, re-parent change and try again
705 Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString());
706 final EntitySet newState = EntitySet.fromQuery(resolver,
707 target.mQuerySelection, null, null);
708 state = EntitySet.mergeAfter(newState, state);
715 private long getRawContactId(EntitySet state,
716 final ArrayList<ContentProviderOperation> diff,
717 final ContentProviderResult[] results) {
718 long rawContactId = state.findRawContactId();
719 if (rawContactId != -1) {
723 // we gotta do some searching for the id
724 final int diffSize = diff.size();
725 for (int i = 0; i < diffSize; i++) {
726 ContentProviderOperation operation = diff.get(i);
727 if (operation.getType() == ContentProviderOperation.TYPE_INSERT
728 && operation.getUri().getEncodedPath().contains(
729 RawContacts.CONTENT_URI.getEncodedPath())) {
730 return ContentUris.parseId(results[i].uri);
738 protected void onPostExecute(EditContactActivity target, Integer result) {
739 final Context context = target;
740 final ProgressDialog progress = mProgress.get();
742 if (result == RESULT_SUCCESS && mSaveMode != SAVE_MODE_JOIN) {
743 Toast.makeText(context, R.string.contactSavedToast, Toast.LENGTH_SHORT).show();
744 } else if (result == RESULT_FAILURE) {
745 Toast.makeText(context, R.string.contactSavedErrorToast, Toast.LENGTH_LONG).show();
748 dismissDialog(progress);
750 // Stop the service that was protecting us
751 context.stopService(new Intent(context, EmptyService.class));
753 target.onSaveCompleted(result != RESULT_FAILURE, mSaveMode, mContactLookupUri);
758 * Saves or creates the contact based on the mode, and if successful
759 * finishes the activity.
761 boolean doSaveAction(int saveMode) {
762 if (!hasValidState()) {
766 mStatus = STATUS_SAVING;
767 final PersistTask task = new PersistTask(this, saveMode);
768 task.execute(mState);
773 private class DeleteClickListener implements DialogInterface.OnClickListener {
775 public void onClick(DialogInterface dialog, int which) {
776 Sources sources = Sources.getInstance(EditContactActivity.this);
777 // Mark all raw contacts for deletion
778 for (EntityDelta delta : mState) {
782 doSaveAction(SAVE_MODE_DEFAULT);
787 private void onSaveCompleted(boolean success, int saveMode, Uri contactLookupUri) {
789 case SAVE_MODE_DEFAULT:
790 if (success && contactLookupUri != null) {
791 final Intent resultIntent = new Intent();
793 final Uri requestData = getIntent().getData();
794 final String requestAuthority = requestData == null ? null : requestData
797 if (android.provider.Contacts.AUTHORITY.equals(requestAuthority)) {
798 // Build legacy Uri when requested by caller
799 final long contactId = ContentUris.parseId(Contacts.lookupContact(
800 getContentResolver(), contactLookupUri));
801 final Uri legacyUri = ContentUris.withAppendedId(
802 android.provider.Contacts.People.CONTENT_URI, contactId);
803 resultIntent.setData(legacyUri);
805 // Otherwise pass back a lookup-style Uri
806 resultIntent.setData(contactLookupUri);
809 setResult(RESULT_OK, resultIntent);
811 setResult(RESULT_CANCELED, null);
816 case SAVE_MODE_SPLIT:
818 Intent intent = new Intent();
819 intent.setData(contactLookupUri);
820 setResult(RESULT_CLOSE_VIEW_ACTIVITY, intent);
826 mStatus = STATUS_EDITING;
828 showJoinAggregateActivity(contactLookupUri);
835 * Shows a list of aggregates that can be joined into the currently viewed aggregate.
837 * @param contactLookupUri the fresh URI for the currently edited contact (after saving it)
839 public void showJoinAggregateActivity(Uri contactLookupUri) {
840 if (contactLookupUri == null) {
844 mContactIdForJoin = ContentUris.parseId(contactLookupUri);
845 Intent intent = new Intent(ContactsListActivity.JOIN_AGGREGATE);
846 intent.putExtra(ContactsListActivity.EXTRA_AGGREGATE_ID, mContactIdForJoin);
847 startActivityForResult(intent, REQUEST_JOIN_CONTACT);
850 private interface JoinContactQuery {
851 String[] PROJECTION = {
853 RawContacts.CONTACT_ID,
854 RawContacts.NAME_VERIFIED,
857 String SELECTION = RawContacts.CONTACT_ID + "=? OR " + RawContacts.CONTACT_ID + "=?";
861 int NAME_VERIFIED = 2;
865 * Performs aggregation with the contact selected by the user from suggestions or A-Z list.
867 private void joinAggregate(final long contactId) {
868 ContentResolver resolver = getContentResolver();
870 // Load raw contact IDs for all raw contacts involved - currently edited and selected
872 Cursor c = resolver.query(RawContacts.CONTENT_URI,
873 JoinContactQuery.PROJECTION,
874 JoinContactQuery.SELECTION,
875 new String[]{String.valueOf(contactId), String.valueOf(mContactIdForJoin)}, null);
877 long rawContactIds[];
878 long verifiedNameRawContactId = -1;
880 rawContactIds = new long[c.getCount()];
881 for (int i = 0; i < rawContactIds.length; i++) {
883 long rawContactId = c.getLong(JoinContactQuery._ID);
884 rawContactIds[i] = rawContactId;
885 if (c.getLong(JoinContactQuery.CONTACT_ID) == mContactIdForJoin) {
886 if (verifiedNameRawContactId == -1
887 || c.getInt(JoinContactQuery.NAME_VERIFIED) != 0) {
888 verifiedNameRawContactId = rawContactId;
896 // For each pair of raw contacts, insert an aggregation exception
897 ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
898 for (int i = 0; i < rawContactIds.length; i++) {
899 for (int j = 0; j < rawContactIds.length; j++) {
901 buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
906 // Mark the original contact as "name verified" to make sure that the contact
907 // display name does not change as a result of the join
908 Builder builder = ContentProviderOperation.newUpdate(
909 ContentUris.withAppendedId(RawContacts.CONTENT_URI, verifiedNameRawContactId));
910 builder.withValue(RawContacts.NAME_VERIFIED, 1);
911 operations.add(builder.build());
913 // Apply all aggregation exceptions as one batch
915 getContentResolver().applyBatch(ContactsContract.AUTHORITY, operations);
917 // We can use any of the constituent raw contacts to refresh the UI - why not the first
918 Intent intent = new Intent();
919 intent.setData(ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0]));
921 // Reload the new state from database
922 new QueryEntitiesTask(this).execute(intent);
924 Toast.makeText(this, R.string.contactsJoinedMessage, Toast.LENGTH_LONG).show();
925 } catch (RemoteException e) {
926 Log.e(TAG, "Failed to apply aggregation exception batch", e);
927 Toast.makeText(this, R.string.contactSavedErrorToast, Toast.LENGTH_LONG).show();
928 } catch (OperationApplicationException e) {
929 Log.e(TAG, "Failed to apply aggregation exception batch", e);
930 Toast.makeText(this, R.string.contactSavedErrorToast, Toast.LENGTH_LONG).show();
935 * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation.
937 private void buildJoinContactDiff(ArrayList<ContentProviderOperation> operations,
938 long rawContactId1, long rawContactId2) {
940 ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
941 builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
942 builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
943 builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
944 operations.add(builder.build());
948 * Revert any changes the user has made, and finish the activity.
950 private boolean doRevertAction() {
956 * Create a new {@link RawContacts} which will exist as another
957 * {@link EntityDelta} under the currently edited {@link Contacts}.
959 private boolean doAddAction() {
960 if (mStatus != STATUS_EDITING) {
964 // Adding is okay when missing state
965 new AddContactTask(this).execute();
970 * Delete the entire contact currently being edited, which usually asks for
971 * user confirmation before continuing.
973 private boolean doDeleteAction() {
974 if (!hasValidState())
976 int readOnlySourcesCnt = 0;
977 int writableSourcesCnt = 0;
978 Sources sources = Sources.getInstance(EditContactActivity.this);
979 for (EntityDelta delta : mState) {
980 final String accountType = delta.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
981 final ContactsSource contactsSource = sources.getInflatedSource(accountType,
982 ContactsSource.LEVEL_CONSTRAINTS);
983 if (contactsSource != null && contactsSource.readOnly) {
984 readOnlySourcesCnt += 1;
986 writableSourcesCnt += 1;
990 if (readOnlySourcesCnt > 0 && writableSourcesCnt > 0) {
991 showDialog(DIALOG_CONFIRM_READONLY_DELETE);
992 } else if (readOnlySourcesCnt > 0 && writableSourcesCnt == 0) {
993 showDialog(DIALOG_CONFIRM_READONLY_HIDE);
994 } else if (readOnlySourcesCnt == 0 && writableSourcesCnt > 1) {
995 showDialog(DIALOG_CONFIRM_MULTIPLE_DELETE);
997 showDialog(DIALOG_CONFIRM_DELETE);
1003 * Pick a specific photo to be added under the currently selected tab.
1005 boolean doPickPhotoAction(long rawContactId) {
1006 if (!hasValidState()) return false;
1008 mRawContactIdRequestingPhoto = rawContactId;
1010 showAndManageDialog(createPickPhotoDialog());
1016 * Creates a dialog offering two options: take a photo or pick a photo from the gallery.
1018 private Dialog createPickPhotoDialog() {
1019 Context context = EditContactActivity.this;
1021 // Wrap our context to inflate list items using correct theme
1022 final Context dialogContext = new ContextThemeWrapper(context,
1023 android.R.style.Theme_Light);
1026 choices = new String[2];
1027 choices[0] = getString(R.string.take_photo);
1028 choices[1] = getString(R.string.pick_photo);
1029 final ListAdapter adapter = new ArrayAdapter<String>(dialogContext,
1030 android.R.layout.simple_list_item_1, choices);
1032 final AlertDialog.Builder builder = new AlertDialog.Builder(dialogContext);
1033 builder.setTitle(R.string.attachToContact);
1034 builder.setSingleChoiceItems(adapter, -1, new DialogInterface.OnClickListener() {
1035 public void onClick(DialogInterface dialog, int which) {
1042 doPickPhotoFromGallery();
1047 return builder.create();
1051 * Create a file name for the icon photo using current time.
1053 private String getPhotoFileName() {
1054 Date date = new Date(System.currentTimeMillis());
1055 SimpleDateFormat dateFormat = new SimpleDateFormat("'IMG'_yyyyMMdd_HHmmss");
1056 return dateFormat.format(date) + ".jpg";
1060 * Launches Camera to take a picture and store it in a file.
1062 protected void doTakePhoto() {
1064 // Launch camera to take photo for selected contact
1066 mCurrentPhotoFile = new File(PHOTO_DIR, getPhotoFileName());
1067 final Intent intent = getTakePickIntent(mCurrentPhotoFile);
1068 startActivityForResult(intent, CAMERA_WITH_DATA);
1069 } catch (ActivityNotFoundException e) {
1070 Toast.makeText(this, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
1075 * Constructs an intent for capturing a photo and storing it in a temporary file.
1077 public static Intent getTakePickIntent(File f) {
1078 Intent intent = new Intent(MediaStore.ACTION_IMAGE_CAPTURE, null);
1079 intent.putExtra(MediaStore.EXTRA_OUTPUT, Uri.fromFile(f));
1084 * Sends a newly acquired photo to Gallery for cropping
1086 protected void doCropPhoto(File f) {
1089 // Add the image to the media store
1090 MediaScannerConnection.scanFile(
1092 new String[] { f.getAbsolutePath() },
1093 new String[] { null },
1096 // Launch gallery to crop the photo
1097 final Intent intent = getCropImageIntent(Uri.fromFile(f));
1098 startActivityForResult(intent, PHOTO_PICKED_WITH_DATA);
1099 } catch (Exception e) {
1100 Log.e(TAG, "Cannot crop image", e);
1101 Toast.makeText(this, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
1106 * Constructs an intent for image cropping.
1108 public static Intent getCropImageIntent(Uri photoUri) {
1109 Intent intent = new Intent("com.android.camera.action.CROP");
1110 intent.setDataAndType(photoUri, "image/*");
1111 intent.putExtra("crop", "true");
1112 intent.putExtra("aspectX", 1);
1113 intent.putExtra("aspectY", 1);
1114 intent.putExtra("outputX", ICON_SIZE);
1115 intent.putExtra("outputY", ICON_SIZE);
1116 intent.putExtra("return-data", true);
1121 * Launches Gallery to pick a photo.
1123 protected void doPickPhotoFromGallery() {
1125 // Launch picker to choose photo for selected contact
1126 final Intent intent = getPhotoPickIntent();
1127 startActivityForResult(intent, PHOTO_PICKED_WITH_DATA);
1128 } catch (ActivityNotFoundException e) {
1129 Toast.makeText(this, R.string.photoPickerNotFoundText, Toast.LENGTH_LONG).show();
1134 * Constructs an intent for picking a photo from Gallery, cropping it and returning the bitmap.
1136 public static Intent getPhotoPickIntent() {
1137 Intent intent = new Intent(Intent.ACTION_GET_CONTENT, null);
1138 intent.setType("image/*");
1139 intent.putExtra("crop", "true");
1140 intent.putExtra("aspectX", 1);
1141 intent.putExtra("aspectY", 1);
1142 intent.putExtra("outputX", ICON_SIZE);
1143 intent.putExtra("outputY", ICON_SIZE);
1144 intent.putExtra("return-data", true);
1148 /** {@inheritDoc} */
1149 public void onDeleted(Editor editor) {
1150 // Ignore any editor deletes
1153 private boolean doSplitContactAction() {
1154 if (!hasValidState()) return false;
1156 showAndManageDialog(createSplitDialog());
1160 private Dialog createSplitDialog() {
1161 final AlertDialog.Builder builder = new AlertDialog.Builder(this);
1162 builder.setTitle(R.string.splitConfirmation_title);
1163 builder.setIcon(android.R.drawable.ic_dialog_alert);
1164 builder.setMessage(R.string.splitConfirmation);
1165 builder.setPositiveButton(android.R.string.ok, new DialogInterface.OnClickListener() {
1166 public void onClick(DialogInterface dialog, int which) {
1167 // Split the contacts
1168 mState.splitRawContacts();
1169 doSaveAction(SAVE_MODE_SPLIT);
1172 builder.setNegativeButton(android.R.string.cancel, null);
1173 builder.setCancelable(false);
1174 return builder.create();
1177 private boolean doJoinContactAction() {
1178 return doSaveAction(SAVE_MODE_JOIN);
1182 * Build dialog that handles adding a new {@link RawContacts} after the user
1183 * picks a specific {@link ContactsSource}.
1185 private static class AddContactTask extends
1186 WeakAsyncTask<Void, Void, ArrayList<Account>, EditContactActivity> {
1188 public AddContactTask(EditContactActivity target) {
1193 protected ArrayList<Account> doInBackground(final EditContactActivity target,
1195 return Sources.getInstance(target).getAccounts(true);
1199 protected void onPostExecute(final EditContactActivity target, ArrayList<Account> accounts) {
1200 target.selectAccountAndCreateContact(accounts);
1204 public void selectAccountAndCreateContact(ArrayList<Account> accounts) {
1205 // No Accounts available. Create a phone-local contact.
1206 if (accounts.isEmpty()) {
1207 createContact(null);
1208 return; // Don't show a dialog.
1211 // In the common case of a single account being writable, auto-select
1212 // it without showing a dialog.
1213 if (accounts.size() == 1) {
1214 createContact(accounts.get(0));
1215 return; // Don't show a dialog.
1218 // Wrap our context to inflate list items using correct theme
1219 final Context dialogContext = new ContextThemeWrapper(this, android.R.style.Theme_Light);
1220 final LayoutInflater dialogInflater =
1221 (LayoutInflater)dialogContext.getSystemService(Context.LAYOUT_INFLATER_SERVICE);
1223 final Sources sources = Sources.getInstance(this);
1225 final ArrayAdapter<Account> accountAdapter = new ArrayAdapter<Account>(this,
1226 android.R.layout.simple_list_item_2, accounts) {
1228 public View getView(int position, View convertView, ViewGroup parent) {
1229 if (convertView == null) {
1230 convertView = dialogInflater.inflate(android.R.layout.simple_list_item_2,
1234 // TODO: show icon along with title
1235 final TextView text1 = (TextView)convertView.findViewById(android.R.id.text1);
1236 final TextView text2 = (TextView)convertView.findViewById(android.R.id.text2);
1238 final Account account = this.getItem(position);
1239 final ContactsSource source = sources.getInflatedSource(account.type,
1240 ContactsSource.LEVEL_SUMMARY);
1242 text1.setText(account.name);
1243 text2.setText(source.getDisplayLabel(EditContactActivity.this));
1249 final DialogInterface.OnClickListener clickListener =
1250 new DialogInterface.OnClickListener() {
1251 public void onClick(DialogInterface dialog, int which) {
1254 // Create new contact based on selected source
1255 final Account account = accountAdapter.getItem(which);
1256 createContact(account);
1260 final DialogInterface.OnCancelListener cancelListener =
1261 new DialogInterface.OnCancelListener() {
1262 public void onCancel(DialogInterface dialog) {
1263 // If nothing remains, close activity
1264 if (!hasValidState()) {
1270 final AlertDialog.Builder builder = new AlertDialog.Builder(this);
1271 builder.setTitle(R.string.dialog_new_contact_account);
1272 builder.setSingleChoiceItems(accountAdapter, 0, clickListener);
1273 builder.setOnCancelListener(cancelListener);
1274 showAndManageDialog(builder.create());
1278 * @param account may be null to signal a device-local contact should
1281 private void createContact(Account account) {
1282 final Sources sources = Sources.getInstance(this);
1283 final ContentValues values = new ContentValues();
1284 if (account != null) {
1285 values.put(RawContacts.ACCOUNT_NAME, account.name);
1286 values.put(RawContacts.ACCOUNT_TYPE, account.type);
1288 values.putNull(RawContacts.ACCOUNT_NAME);
1289 values.putNull(RawContacts.ACCOUNT_TYPE);
1292 // Parse any values from incoming intent
1293 EntityDelta insert = new EntityDelta(ValuesDelta.fromAfter(values));
1294 final ContactsSource source = sources.getInflatedSource(
1295 account != null ? account.type : null,
1296 ContactsSource.LEVEL_CONSTRAINTS);
1297 final Bundle extras = getIntent().getExtras();
1298 EntityModifier.parseExtras(this, source, insert, extras);
1300 // Ensure we have some default fields
1301 EntityModifier.ensureKindExists(insert, source, Phone.CONTENT_ITEM_TYPE);
1302 EntityModifier.ensureKindExists(insert, source, Email.CONTENT_ITEM_TYPE);
1304 // Create "My Contacts" membership for Google contacts
1305 // TODO: move this off into "templates" for each given source
1306 if (GoogleSource.ACCOUNT_TYPE.equals(source.accountType)) {
1307 GoogleSource.attemptMyContactsMembership(insert, this);
1310 if (mState == null) {
1311 // Create state if none exists yet
1312 mState = EntitySet.fromSingle(insert);
1314 // Add contact onto end of existing state
1322 * Compare EntityDeltas for sorting the stack of editors.
1324 public int compare(EntityDelta one, EntityDelta two) {
1325 // Check direct equality
1326 if (one.equals(two)) {
1330 final Sources sources = Sources.getInstance(this);
1331 String accountType = one.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
1332 final ContactsSource oneSource = sources.getInflatedSource(accountType,
1333 ContactsSource.LEVEL_SUMMARY);
1334 accountType = two.getValues().getAsString(RawContacts.ACCOUNT_TYPE);
1335 final ContactsSource twoSource = sources.getInflatedSource(accountType,
1336 ContactsSource.LEVEL_SUMMARY);
1339 if (oneSource.readOnly && !twoSource.readOnly) {
1341 } else if (twoSource.readOnly && !oneSource.readOnly) {
1345 // Check account type
1346 boolean skipAccountTypeCheck = false;
1347 boolean oneIsGoogle = oneSource instanceof GoogleSource;
1348 boolean twoIsGoogle = twoSource instanceof GoogleSource;
1349 if (oneIsGoogle && !twoIsGoogle) {
1351 } else if (twoIsGoogle && !oneIsGoogle) {
1354 skipAccountTypeCheck = true;
1358 if (!skipAccountTypeCheck) {
1359 value = oneSource.accountType.compareTo(twoSource.accountType);
1365 // Check account name
1366 ValuesDelta oneValues = one.getValues();
1367 String oneAccount = oneValues.getAsString(RawContacts.ACCOUNT_NAME);
1368 if (oneAccount == null) oneAccount = "";
1369 ValuesDelta twoValues = two.getValues();
1370 String twoAccount = twoValues.getAsString(RawContacts.ACCOUNT_NAME);
1371 if (twoAccount == null) twoAccount = "";
1372 value = oneAccount.compareTo(twoAccount);
1377 // Both are in the same account, fall back to contact ID
1378 long oneId = oneValues.getAsLong(RawContacts._ID);
1379 long twoId = twoValues.getAsLong(RawContacts._ID);
1380 return (int)(oneId - twoId);
1384 public void startSearch(String initialQuery, boolean selectInitialQuery, Bundle appSearchData,
1385 boolean globalSearch) {
1387 super.startSearch(initialQuery, selectInitialQuery, appSearchData, globalSearch);
1389 ContactsSearchManager.startSearch(this, initialQuery);