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