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