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