/* * Copyright (C) 2010 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package com.android.contacts; import static android.Manifest.permission.WRITE_CONTACTS; import android.app.Activity; import android.app.IntentService; import android.content.ContentProviderOperation; import android.content.ContentProviderOperation.Builder; import android.content.ContentProviderResult; import android.content.ContentResolver; import android.content.ContentUris; import android.content.ContentValues; import android.content.Context; import android.content.Intent; import android.content.OperationApplicationException; import android.database.Cursor; import android.database.DatabaseUtils; import android.icu.text.MessageFormat; import android.net.Uri; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.os.Parcelable; import android.os.RemoteException; import android.provider.ContactsContract; import android.provider.ContactsContract.AggregationExceptions; import android.provider.ContactsContract.CommonDataKinds.GroupMembership; import android.provider.ContactsContract.CommonDataKinds.StructuredName; import android.provider.ContactsContract.Contacts; import android.provider.ContactsContract.Data; import android.provider.ContactsContract.Groups; import android.provider.ContactsContract.Profile; import android.provider.ContactsContract.RawContacts; import android.provider.ContactsContract.RawContactsEntity; import androidx.localbroadcastmanager.content.LocalBroadcastManager; import android.support.v4.os.ResultReceiver; import android.text.TextUtils; import android.util.Log; import android.widget.Toast; import com.android.contacts.activities.ContactEditorActivity; import com.android.contacts.compat.CompatUtils; import com.android.contacts.compat.PinnedPositionsCompat; import com.android.contacts.database.ContactUpdateUtils; import com.android.contacts.database.SimContactDao; import com.android.contacts.model.AccountTypeManager; import com.android.contacts.model.CPOWrapper; import com.android.contacts.model.RawContactDelta; import com.android.contacts.model.RawContactDeltaList; import com.android.contacts.model.RawContactModifier; import com.android.contacts.model.account.AccountWithDataSet; import com.android.contacts.preference.ContactsPreferences; import com.android.contacts.util.ContactDisplayUtils; import com.android.contacts.util.ContactPhotoUtils; import com.android.contacts.util.PermissionsUtil; import com.android.contactsbind.FeedbackHelper; import com.google.common.collect.Lists; import com.google.common.collect.Sets; import java.util.ArrayList; import java.util.Collection; import java.util.HashMap; import java.util.HashSet; import java.util.List; import java.util.Locale; import java.util.Map; import java.util.concurrent.CopyOnWriteArrayList; /** * A service responsible for saving changes to the content provider. */ public class ContactSaveService extends IntentService { private static final String TAG = "ContactSaveService"; /** Set to true in order to view logs on content provider operations */ private static final boolean DEBUG = false; public static final String ACTION_NEW_RAW_CONTACT = "newRawContact"; public static final String EXTRA_ACCOUNT_NAME = "accountName"; public static final String EXTRA_ACCOUNT_TYPE = "accountType"; public static final String EXTRA_DATA_SET = "dataSet"; public static final String EXTRA_ACCOUNT = "account"; public static final String EXTRA_CONTENT_VALUES = "contentValues"; public static final String EXTRA_CALLBACK_INTENT = "callbackIntent"; public static final String EXTRA_RESULT_RECEIVER = "resultReceiver"; public static final String EXTRA_RAW_CONTACT_IDS = "rawContactIds"; public static final String ACTION_SAVE_CONTACT = "saveContact"; public static final String EXTRA_CONTACT_STATE = "state"; public static final String EXTRA_SAVE_MODE = "saveMode"; public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile"; public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded"; public static final String EXTRA_UPDATED_PHOTOS = "updatedPhotos"; public static final String ACTION_CREATE_GROUP = "createGroup"; public static final String ACTION_RENAME_GROUP = "renameGroup"; public static final String ACTION_DELETE_GROUP = "deleteGroup"; public static final String ACTION_UPDATE_GROUP = "updateGroup"; public static final String EXTRA_GROUP_ID = "groupId"; public static final String EXTRA_GROUP_LABEL = "groupLabel"; public static final String EXTRA_RAW_CONTACTS_TO_ADD = "rawContactsToAdd"; public static final String EXTRA_RAW_CONTACTS_TO_REMOVE = "rawContactsToRemove"; public static final String ACTION_SET_STARRED = "setStarred"; public static final String ACTION_DELETE_CONTACT = "delete"; public static final String ACTION_DELETE_MULTIPLE_CONTACTS = "deleteMultipleContacts"; public static final String EXTRA_CONTACT_URI = "contactUri"; public static final String EXTRA_CONTACT_IDS = "contactIds"; public static final String EXTRA_STARRED_FLAG = "starred"; public static final String EXTRA_DISPLAY_NAME = "extraDisplayName"; public static final String EXTRA_DISPLAY_NAME_ARRAY = "extraDisplayNameArray"; public static final String ACTION_SET_SUPER_PRIMARY = "setSuperPrimary"; public static final String ACTION_CLEAR_PRIMARY = "clearPrimary"; public static final String EXTRA_DATA_ID = "dataId"; public static final String ACTION_SPLIT_CONTACT = "splitContact"; public static final String EXTRA_HARD_SPLIT = "extraHardSplit"; public static final String ACTION_JOIN_CONTACTS = "joinContacts"; public static final String ACTION_JOIN_SEVERAL_CONTACTS = "joinSeveralContacts"; public static final String EXTRA_CONTACT_ID1 = "contactId1"; public static final String EXTRA_CONTACT_ID2 = "contactId2"; public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail"; public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag"; public static final String ACTION_SET_RINGTONE = "setRingtone"; public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone"; public static final String ACTION_UNDO = "undo"; public static final String EXTRA_UNDO_ACTION = "undoAction"; public static final String EXTRA_UNDO_DATA = "undoData"; // For debugging and testing what happens when requests are queued up. public static final String ACTION_SLEEP = "sleep"; public static final String EXTRA_SLEEP_DURATION = "sleepDuration"; public static final String BROADCAST_GROUP_DELETED = "groupDeleted"; public static final String BROADCAST_LINK_COMPLETE = "linkComplete"; public static final String BROADCAST_UNLINK_COMPLETE = "unlinkComplete"; public static final String BROADCAST_SERVICE_STATE_CHANGED = "serviceStateChanged"; public static final String EXTRA_RESULT_CODE = "resultCode"; public static final String EXTRA_RESULT_COUNT = "count"; public static final int CP2_ERROR = 0; public static final int CONTACTS_LINKED = 1; public static final int CONTACTS_SPLIT = 2; public static final int BAD_ARGUMENTS = 3; public static final int RESULT_UNKNOWN = 0; public static final int RESULT_SUCCESS = 1; public static final int RESULT_FAILURE = 2; private static final HashSet ALLOWED_DATA_COLUMNS = Sets.newHashSet( Data.MIMETYPE, Data.IS_PRIMARY, Data.DATA1, Data.DATA2, Data.DATA3, Data.DATA4, Data.DATA5, Data.DATA6, Data.DATA7, Data.DATA8, Data.DATA9, Data.DATA10, Data.DATA11, Data.DATA12, Data.DATA13, Data.DATA14, Data.DATA15 ); private static final int PERSIST_TRIES = 3; private static final int MAX_CONTACTS_PROVIDER_BATCH_SIZE = 499; public interface Listener { public void onServiceCompleted(Intent callbackIntent); } private static final CopyOnWriteArrayList sListeners = new CopyOnWriteArrayList(); // Holds the current state of the service private static final State sState = new State(); private Handler mMainHandler; private GroupsDao mGroupsDao; private SimContactDao mSimContactDao; public ContactSaveService() { super(TAG); setIntentRedelivery(true); mMainHandler = new Handler(Looper.getMainLooper()); } @Override public void onCreate() { super.onCreate(); mGroupsDao = new GroupsDaoImpl(this); mSimContactDao = SimContactDao.create(this); } public static void registerListener(Listener listener) { if (!(listener instanceof Activity)) { throw new ClassCastException("Only activities can be registered to" + " receive callback from " + ContactSaveService.class.getName()); } sListeners.add(0, listener); } public static boolean canUndo(Intent resultIntent) { return resultIntent.hasExtra(EXTRA_UNDO_DATA); } public static void unregisterListener(Listener listener) { sListeners.remove(listener); } public static State getState() { return sState; } private void notifyStateChanged() { LocalBroadcastManager.getInstance(this) .sendBroadcast(new Intent(BROADCAST_SERVICE_STATE_CHANGED)); } /** * Returns true if the ContactSaveService was started successfully and false if an exception * was thrown and a Toast error message was displayed. */ public static boolean startService(Context context, Intent intent, int saveMode) { try { context.startService(intent); } catch (Exception exception) { final int resId; switch (saveMode) { case ContactEditorActivity.ContactEditor.SaveMode.SPLIT: resId = R.string.contactUnlinkErrorToast; break; case ContactEditorActivity.ContactEditor.SaveMode.RELOAD: resId = R.string.contactJoinErrorToast; break; case ContactEditorActivity.ContactEditor.SaveMode.CLOSE: resId = R.string.contactSavedErrorToast; break; default: resId = R.string.contactGenericErrorToast; } Toast.makeText(context, resId, Toast.LENGTH_SHORT).show(); return false; } return true; } /** * Utility method that starts service and handles exception. */ public static void startService(Context context, Intent intent) { try { context.startService(intent); } catch (Exception exception) { Toast.makeText(context, R.string.contactGenericErrorToast, Toast.LENGTH_SHORT).show(); } } @Override public Object getSystemService(String name) { Object service = super.getSystemService(name); if (service != null) { return service; } return getApplicationContext().getSystemService(name); } // Parent classes Javadoc says not to override this method but we're doing it just to update // our state which should be OK since we're still doing the work in onHandleIntent @Override public int onStartCommand(Intent intent, int flags, int startId) { sState.onStart(intent); notifyStateChanged(); return super.onStartCommand(intent, flags, startId); } @Override protected void onHandleIntent(final Intent intent) { if (intent == null) { if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "onHandleIntent: could not handle null intent"); } return; } if (!PermissionsUtil.hasPermission(this, WRITE_CONTACTS)) { Log.w(TAG, "No WRITE_CONTACTS permission, unable to write to CP2"); // TODO: add more specific error string such as "Turn on Contacts // permission to update your contacts" showToast(R.string.contactSavedErrorToast); return; } // Call an appropriate method. If we're sure it affects how incoming phone calls are // handled, then notify the fact to in-call screen. String action = intent.getAction(); if (ACTION_NEW_RAW_CONTACT.equals(action)) { createRawContact(intent); } else if (ACTION_SAVE_CONTACT.equals(action)) { saveContact(intent); } else if (ACTION_CREATE_GROUP.equals(action)) { createGroup(intent); } else if (ACTION_RENAME_GROUP.equals(action)) { renameGroup(intent); } else if (ACTION_DELETE_GROUP.equals(action)) { deleteGroup(intent); } else if (ACTION_UPDATE_GROUP.equals(action)) { updateGroup(intent); } else if (ACTION_SET_STARRED.equals(action)) { setStarred(intent); } else if (ACTION_SET_SUPER_PRIMARY.equals(action)) { setSuperPrimary(intent); } else if (ACTION_CLEAR_PRIMARY.equals(action)) { clearPrimary(intent); } else if (ACTION_DELETE_MULTIPLE_CONTACTS.equals(action)) { deleteMultipleContacts(intent); } else if (ACTION_DELETE_CONTACT.equals(action)) { deleteContact(intent); } else if (ACTION_SPLIT_CONTACT.equals(action)) { splitContact(intent); } else if (ACTION_JOIN_CONTACTS.equals(action)) { joinContacts(intent); } else if (ACTION_JOIN_SEVERAL_CONTACTS.equals(action)) { joinSeveralContacts(intent); } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) { setSendToVoicemail(intent); } else if (ACTION_SET_RINGTONE.equals(action)) { setRingtone(intent); } else if (ACTION_UNDO.equals(action)) { undo(intent); } else if (ACTION_SLEEP.equals(action)) { sleepForDebugging(intent); } sState.onFinish(intent); notifyStateChanged(); } /** * Creates an intent that can be sent to this service to create a new raw contact * using data presented as a set of ContentValues. */ public static Intent createNewRawContactIntent(Context context, ArrayList values, AccountWithDataSet account, Class callbackActivity, String callbackAction) { Intent serviceIntent = new Intent( context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_NEW_RAW_CONTACT); if (account != null) { serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name); serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type); serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet); } serviceIntent.putParcelableArrayListExtra( ContactSaveService.EXTRA_CONTENT_VALUES, values); // Callback intent will be invoked by the service once the new contact is // created. The service will put the URI of the new contact as "data" on // the callback intent. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void createRawContact(Intent intent) { String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME); String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE); String dataSet = intent.getStringExtra(EXTRA_DATA_SET); List valueList = intent.getParcelableArrayListExtra(EXTRA_CONTENT_VALUES); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); ArrayList operations = new ArrayList(); operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI) .withValue(RawContacts.ACCOUNT_NAME, accountName) .withValue(RawContacts.ACCOUNT_TYPE, accountType) .withValue(RawContacts.DATA_SET, dataSet) .build()); int size = valueList.size(); for (int i = 0; i < size; i++) { ContentValues values = valueList.get(i); values.keySet().retainAll(ALLOWED_DATA_COLUMNS); operations.add(ContentProviderOperation.newInsert(Data.CONTENT_URI) .withValueBackReference(Data.RAW_CONTACT_ID, 0) .withValues(values) .build()); } ContentResolver resolver = getContentResolver(); ContentProviderResult[] results; try { results = resolver.applyBatch(ContactsContract.AUTHORITY, operations); } catch (Exception e) { throw new RuntimeException("Failed to store new contact", e); } Uri rawContactUri = results[0].uri; callbackIntent.setData(RawContacts.getContactLookupUri(resolver, rawContactUri)); deliverCallback(callbackIntent); } /** * Creates an intent that can be sent to this service to create a new raw contact * using data presented as a set of ContentValues. * This variant is more convenient to use when there is only one photo that can * possibly be updated, as in the Contact Details screen. * @param rawContactId identifies a writable raw-contact whose photo is to be updated. * @param updatedPhotoPath denotes a temporary file containing the contact's new photo. */ public static Intent createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class callbackActivity, String callbackAction, long rawContactId, Uri updatedPhotoPath) { Bundle bundle = new Bundle(); bundle.putParcelable(String.valueOf(rawContactId), updatedPhotoPath); return createSaveContactIntent(context, state, saveModeExtraKey, saveMode, isProfile, callbackActivity, callbackAction, bundle, /* joinContactIdExtraKey */ null, /* joinContactId */ null); } /** * Creates an intent that can be sent to this service to create a new raw contact * using data presented as a set of ContentValues. * This variant is used when multiple contacts' photos may be updated, as in the * Contact Editor. * * @param updatedPhotos maps each raw-contact's ID to the file-path of the new photo. * @param joinContactIdExtraKey the key used to pass the joinContactId in the callback intent. * @param joinContactId the raw contact ID to join to the contact after doing the save. */ public static Intent createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class callbackActivity, String callbackAction, Bundle updatedPhotos, String joinContactIdExtraKey, Long joinContactId) { Intent serviceIntent = new Intent( context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SAVE_CONTACT); serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state); serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile); serviceIntent.putExtra(EXTRA_SAVE_MODE, saveMode); if (updatedPhotos != null) { serviceIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos); } if (callbackActivity != null) { // Callback intent will be invoked by the service once the contact is // saved. The service will put the URI of the new contact as "data" on // the callback intent. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.putExtra(saveModeExtraKey, saveMode); if (joinContactIdExtraKey != null && joinContactId != null) { callbackIntent.putExtra(joinContactIdExtraKey, joinContactId); } callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); } return serviceIntent; } private void saveContact(Intent intent) { RawContactDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE); boolean isProfile = intent.getBooleanExtra(EXTRA_SAVE_IS_PROFILE, false); Bundle updatedPhotos = intent.getParcelableExtra(EXTRA_UPDATED_PHOTOS); if (state == null) { Log.e(TAG, "Invalid arguments for saveContact request"); return; } int saveMode = intent.getIntExtra(EXTRA_SAVE_MODE, -1); // Trim any empty fields, and RawContacts, before persisting final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this); RawContactModifier.trimEmpty(state, accountTypes); Uri lookupUri = null; final ContentResolver resolver = getContentResolver(); boolean succeeded = false; // Keep track of the id of a newly raw-contact (if any... there can be at most one). long insertedRawContactId = -1; // Attempt to persist changes int tries = 0; while (tries++ < PERSIST_TRIES) { try { // Build operations and try applying final ArrayList diffWrapper = state.buildDiffWrapper(); final ArrayList diff = Lists.newArrayList(); for (CPOWrapper cpoWrapper : diffWrapper) { diff.add(cpoWrapper.getOperation()); } if (DEBUG) { Log.v(TAG, "Content Provider Operations:"); for (ContentProviderOperation operation : diff) { Log.v(TAG, operation.toString()); } } int numberProcessed = 0; boolean batchFailed = false; final ContentProviderResult[] results = new ContentProviderResult[diff.size()]; while (numberProcessed < diff.size()) { final int subsetCount = applyDiffSubset(diff, numberProcessed, results, resolver); if (subsetCount == -1) { Log.w(TAG, "Resolver.applyBatch failed in saveContacts"); batchFailed = true; break; } else { numberProcessed += subsetCount; } } if (batchFailed) { // Retry save continue; } final long rawContactId = getRawContactId(state, diffWrapper, results); if (rawContactId == -1) { throw new IllegalStateException("Could not determine RawContact ID after save"); } // We don't have to check to see if the value is still -1. If we reach here, // the previous loop iteration didn't succeed, so any ID that we obtained is bogus. insertedRawContactId = getInsertedRawContactId(diffWrapper, results); if (isProfile) { // Since the profile supports local raw contacts, which may have been completely // removed if all information was removed, we need to do a special query to // get the lookup URI for the profile contact (if it still exists). Cursor c = resolver.query(Profile.CONTENT_URI, new String[] {Contacts._ID, Contacts.LOOKUP_KEY}, null, null, null); if (c == null) { continue; } try { if (c.moveToFirst()) { final long contactId = c.getLong(0); final String lookupKey = c.getString(1); lookupUri = Contacts.getLookupUri(contactId, lookupKey); } } finally { c.close(); } } else { final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId); lookupUri = RawContacts.getContactLookupUri(resolver, rawContactUri); } if (lookupUri != null && Log.isLoggable(TAG, Log.VERBOSE)) { Log.v(TAG, "Saved contact. New URI: " + lookupUri); } // We can change this back to false later, if we fail to save the contact photo. succeeded = true; break; } catch (RemoteException e) { // Something went wrong, bail without success FeedbackHelper.sendFeedback(this, TAG, "Problem persisting user edits", e); break; } catch (IllegalArgumentException e) { // This is thrown by applyBatch on malformed requests FeedbackHelper.sendFeedback(this, TAG, "Problem persisting user edits", e); showToast(R.string.contactSavedErrorToast); break; } catch (OperationApplicationException e) { // Version consistency failed, re-parent change and try again Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString()); final StringBuilder sb = new StringBuilder(RawContacts._ID + " IN("); boolean first = true; final int count = state.size(); for (int i = 0; i < count; i++) { Long rawContactId = state.getRawContactId(i); if (rawContactId != null && rawContactId != -1) { if (!first) { sb.append(','); } sb.append(rawContactId); first = false; } } sb.append(")"); if (first) { throw new IllegalStateException( "Version consistency failed for a new contact", e); } final RawContactDeltaList newState = RawContactDeltaList.fromQuery( isProfile ? RawContactsEntity.PROFILE_CONTENT_URI : RawContactsEntity.CONTENT_URI, resolver, sb.toString(), null, null); state = RawContactDeltaList.mergeAfter(newState, state); // Update the new state to use profile URIs if appropriate. if (isProfile) { for (RawContactDelta delta : state) { delta.setProfileQueryUri(); } } } } // Now save any updated photos. We do this at the end to ensure that // the ContactProvider already knows about newly-created contacts. if (updatedPhotos != null) { for (String key : updatedPhotos.keySet()) { Uri photoUri = updatedPhotos.getParcelable(key); long rawContactId = Long.parseLong(key); // If the raw-contact ID is negative, we are saving a new raw-contact; // replace the bogus ID with the new one that we actually saved the contact at. if (rawContactId < 0) { rawContactId = insertedRawContactId; } // If the save failed, insertedRawContactId will be -1 if (rawContactId < 0 || !saveUpdatedPhoto(rawContactId, photoUri, saveMode)) { succeeded = false; } } } Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); if (callbackIntent != null) { if (succeeded) { // Mark the intent to indicate that the save was successful (even if the lookup URI // is now null). For local contacts or the local profile, it's possible that the // save triggered removal of the contact, so no lookup URI would exist.. callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true); } callbackIntent.setData(lookupUri); deliverCallback(callbackIntent); } } /** * Splits "diff" into subsets based on "MAX_CONTACTS_PROVIDER_BATCH_SIZE", applies each of the * subsets, adds the returned array to "results". * * @return the size of the array, if not null; -1 when the array is null. */ private int applyDiffSubset(ArrayList diff, int offset, ContentProviderResult[] results, ContentResolver resolver) throws RemoteException, OperationApplicationException { final int subsetCount = Math.min(diff.size() - offset, MAX_CONTACTS_PROVIDER_BATCH_SIZE); final ArrayList subset = new ArrayList<>(); subset.addAll(diff.subList(offset, offset + subsetCount)); final ContentProviderResult[] subsetResult = resolver.applyBatch(ContactsContract .AUTHORITY, subset); if (subsetResult == null || (offset + subsetResult.length) > results.length) { return -1; } for (ContentProviderResult c : subsetResult) { results[offset++] = c; } return subsetResult.length; } /** * Save updated photo for the specified raw-contact. * @return true for success, false for failure */ private boolean saveUpdatedPhoto(long rawContactId, Uri photoUri, int saveMode) { final Uri outputUri = Uri.withAppendedPath( ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId), RawContacts.DisplayPhoto.CONTENT_DIRECTORY); return ContactPhotoUtils.savePhotoFromUriToUri(this, photoUri, outputUri, (saveMode == 0)); } /** * Find the ID of an existing or newly-inserted raw-contact. If none exists, return -1. */ private long getRawContactId(RawContactDeltaList state, final ArrayList diffWrapper, final ContentProviderResult[] results) { long existingRawContactId = state.findRawContactId(); if (existingRawContactId != -1) { return existingRawContactId; } return getInsertedRawContactId(diffWrapper, results); } /** * Find the ID of a newly-inserted raw-contact. If none exists, return -1. */ private long getInsertedRawContactId( final ArrayList diffWrapper, final ContentProviderResult[] results) { if (results == null) { return -1; } final int diffSize = diffWrapper.size(); final int numResults = results.length; for (int i = 0; i < diffSize && i < numResults; i++) { final CPOWrapper cpoWrapper = diffWrapper.get(i); final boolean isInsert = CompatUtils.isInsertCompat(cpoWrapper); if (isInsert && cpoWrapper.getOperation().getUri().getEncodedPath().contains( RawContacts.CONTENT_URI.getEncodedPath())) { return ContentUris.parseId(results[i].uri); } } return -1; } /** * Creates an intent that can be sent to this service to create a new group as * well as add new members at the same time. * * @param context of the application * @param account in which the group should be created * @param label is the name of the group (cannot be null) * @param rawContactsToAdd is an array of raw contact IDs for contacts that * should be added to the group * @param callbackActivity is the activity to send the callback intent to * @param callbackAction is the intent action for the callback intent */ public static Intent createNewGroupIntent(Context context, AccountWithDataSet account, String label, long[] rawContactsToAdd, Class callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_CREATE_GROUP); serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type); serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name); serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet); serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, label); serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd); // Callback intent will be invoked by the service once the new group is // created. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void createGroup(Intent intent) { String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE); String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME); String dataSet = intent.getStringExtra(EXTRA_DATA_SET); String label = intent.getStringExtra(EXTRA_GROUP_LABEL); final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD); // Create the new group final Uri groupUri = mGroupsDao.create(label, new AccountWithDataSet(accountName, accountType, dataSet)); final ContentResolver resolver = getContentResolver(); // If there's no URI, then the insertion failed. Abort early because group members can't be // added if the group doesn't exist if (groupUri == null) { Log.e(TAG, "Couldn't create group with label " + label); return; } // Add new group members addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri)); ContentValues values = new ContentValues(); // TODO: Move this into the contact editor where it belongs. This needs to be integrated // with the way other intent extras that are passed to the // {@link ContactEditorActivity}. values.clear(); values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); values.put(GroupMembership.GROUP_ROW_ID, ContentUris.parseId(groupUri)); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); callbackIntent.setData(groupUri); // TODO: This can be taken out when the above TODO is addressed callbackIntent.putExtra(ContactsContract.Intents.Insert.DATA, Lists.newArrayList(values)); deliverCallback(callbackIntent); } /** * Creates an intent that can be sent to this service to rename a group. */ public static Intent createGroupRenameIntent(Context context, long groupId, String newLabel, Class callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_RENAME_GROUP); serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId); serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel); // Callback intent will be invoked by the service once the group is renamed. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void renameGroup(Intent intent) { long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1); String label = intent.getStringExtra(EXTRA_GROUP_LABEL); if (groupId == -1) { Log.e(TAG, "Invalid arguments for renameGroup request"); return; } ContentValues values = new ContentValues(); values.put(Groups.TITLE, label); final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId); getContentResolver().update(groupUri, values, null, null); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); callbackIntent.setData(groupUri); deliverCallback(callbackIntent); } /** * Creates an intent that can be sent to this service to delete a group. */ public static Intent createGroupDeletionIntent(Context context, long groupId) { final Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_DELETE_GROUP); serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId); return serviceIntent; } private void deleteGroup(Intent intent) { long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1); if (groupId == -1) { Log.e(TAG, "Invalid arguments for deleteGroup request"); return; } final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId); final Intent callbackIntent = new Intent(BROADCAST_GROUP_DELETED); final Bundle undoData = mGroupsDao.captureDeletionUndoData(groupUri); callbackIntent.putExtra(EXTRA_UNDO_ACTION, ACTION_DELETE_GROUP); callbackIntent.putExtra(EXTRA_UNDO_DATA, undoData); mGroupsDao.delete(groupUri); LocalBroadcastManager.getInstance(this).sendBroadcast(callbackIntent); } public static Intent createUndoIntent(Context context, Intent resultIntent) { final Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_UNDO); serviceIntent.putExtras(resultIntent); return serviceIntent; } private void undo(Intent intent) { final String actionToUndo = intent.getStringExtra(EXTRA_UNDO_ACTION); if (ACTION_DELETE_GROUP.equals(actionToUndo)) { mGroupsDao.undoDeletion(intent.getBundleExtra(EXTRA_UNDO_DATA)); } } /** * Creates an intent that can be sent to this service to rename a group as * well as add and remove members from the group. * * @param context of the application * @param groupId of the group that should be modified * @param newLabel is the updated name of the group (can be null if the name * should not be updated) * @param rawContactsToAdd is an array of raw contact IDs for contacts that * should be added to the group * @param rawContactsToRemove is an array of raw contact IDs for contacts * that should be removed from the group * @param callbackActivity is the activity to send the callback intent to * @param callbackAction is the intent action for the callback intent */ public static Intent createGroupUpdateIntent(Context context, long groupId, String newLabel, long[] rawContactsToAdd, long[] rawContactsToRemove, Class callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_UPDATE_GROUP); serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId); serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel); serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd); serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_REMOVE, rawContactsToRemove); // Callback intent will be invoked by the service once the group is updated Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } private void updateGroup(Intent intent) { long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1); String label = intent.getStringExtra(EXTRA_GROUP_LABEL); long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD); long[] rawContactsToRemove = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_REMOVE); if (groupId == -1) { Log.e(TAG, "Invalid arguments for updateGroup request"); return; } final ContentResolver resolver = getContentResolver(); final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId); // Update group name if necessary if (label != null) { ContentValues values = new ContentValues(); values.put(Groups.TITLE, label); resolver.update(groupUri, values, null, null); } // Add and remove members if necessary addMembersToGroup(resolver, rawContactsToAdd, groupId); removeMembersFromGroup(resolver, rawContactsToRemove, groupId); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); callbackIntent.setData(groupUri); deliverCallback(callbackIntent); } private void addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd, long groupId) { if (rawContactsToAdd == null) { return; } for (long rawContactId : rawContactsToAdd) { try { final ArrayList rawContactOperations = new ArrayList(); // Build an assert operation to ensure the contact is not already in the group final ContentProviderOperation.Builder assertBuilder = ContentProviderOperation .newAssertQuery(Data.CONTENT_URI); assertBuilder.withSelection(Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?", new String[] { String.valueOf(rawContactId), GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)}); assertBuilder.withExpectedCount(0); rawContactOperations.add(assertBuilder.build()); // Build an insert operation to add the contact to the group final ContentProviderOperation.Builder insertBuilder = ContentProviderOperation .newInsert(Data.CONTENT_URI); insertBuilder.withValue(Data.RAW_CONTACT_ID, rawContactId); insertBuilder.withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); insertBuilder.withValue(GroupMembership.GROUP_ROW_ID, groupId); rawContactOperations.add(insertBuilder.build()); if (DEBUG) { for (ContentProviderOperation operation : rawContactOperations) { Log.v(TAG, operation.toString()); } } // Apply batch if (!rawContactOperations.isEmpty()) { resolver.applyBatch(ContactsContract.AUTHORITY, rawContactOperations); } } catch (RemoteException e) { // Something went wrong, bail without success FeedbackHelper.sendFeedback(this, TAG, "Problem persisting user edits for raw contact ID " + String.valueOf(rawContactId), e); } catch (OperationApplicationException e) { // The assert could have failed because the contact is already in the group, // just continue to the next contact FeedbackHelper.sendFeedback(this, TAG, "Assert failed in adding raw contact ID " + String.valueOf(rawContactId) + ". Already exists in group " + String.valueOf(groupId), e); } } } private static void removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove, long groupId) { if (rawContactsToRemove == null) { return; } for (long rawContactId : rawContactsToRemove) { // Apply the delete operation on the data row for the given raw contact's // membership in the given group. If no contact matches the provided selection, then // nothing will be done. Just continue to the next contact. resolver.delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=? AND " + Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?", new String[] { String.valueOf(rawContactId), GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)}); } } /** * Creates an intent that can be sent to this service to star or un-star a contact. */ public static Intent createSetStarredIntent(Context context, Uri contactUri, boolean value) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SET_STARRED); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri); serviceIntent.putExtra(ContactSaveService.EXTRA_STARRED_FLAG, value); return serviceIntent; } private void setStarred(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); boolean value = intent.getBooleanExtra(EXTRA_STARRED_FLAG, false); if (contactUri == null) { Log.e(TAG, "Invalid arguments for setStarred request"); return; } final ContentValues values = new ContentValues(1); values.put(Contacts.STARRED, value); getContentResolver().update(contactUri, values, null, null); // Undemote the contact if necessary final Cursor c = getContentResolver().query(contactUri, new String[] {Contacts._ID}, null, null, null); if (c == null) { return; } try { if (c.moveToFirst()) { final long id = c.getLong(0); // Don't bother undemoting if this contact is the user's profile. if (id < Profile.MIN_ID) { PinnedPositionsCompat.undemote(getContentResolver(), id); } } } finally { c.close(); } } /** * Creates an intent that can be sent to this service to set the redirect to voicemail. */ public static Intent createSetSendToVoicemail(Context context, Uri contactUri, boolean value) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SET_SEND_TO_VOICEMAIL); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri); serviceIntent.putExtra(ContactSaveService.EXTRA_SEND_TO_VOICEMAIL_FLAG, value); return serviceIntent; } private void setSendToVoicemail(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); boolean value = intent.getBooleanExtra(EXTRA_SEND_TO_VOICEMAIL_FLAG, false); if (contactUri == null) { Log.e(TAG, "Invalid arguments for setRedirectToVoicemail"); return; } final ContentValues values = new ContentValues(1); values.put(Contacts.SEND_TO_VOICEMAIL, value); getContentResolver().update(contactUri, values, null, null); } /** * Creates an intent that can be sent to this service to save the contact's ringtone. */ public static Intent createSetRingtone(Context context, Uri contactUri, String value) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SET_RINGTONE); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri); serviceIntent.putExtra(ContactSaveService.EXTRA_CUSTOM_RINGTONE, value); return serviceIntent; } private void setRingtone(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); String value = intent.getStringExtra(EXTRA_CUSTOM_RINGTONE); if (contactUri == null) { Log.e(TAG, "Invalid arguments for setRingtone"); return; } ContentValues values = new ContentValues(1); values.put(Contacts.CUSTOM_RINGTONE, value); getContentResolver().update(contactUri, values, null, null); } /** * Creates an intent that sets the selected data item as super primary (default) */ public static Intent createSetSuperPrimaryIntent(Context context, long dataId) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SET_SUPER_PRIMARY); serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId); return serviceIntent; } private void setSuperPrimary(Intent intent) { long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1); if (dataId == -1) { Log.e(TAG, "Invalid arguments for setSuperPrimary request"); return; } ContactUpdateUtils.setSuperPrimary(this, dataId); } /** * Creates an intent that clears the primary flag of all data items that belong to the same * raw_contact as the given data item. Will only clear, if the data item was primary before * this call */ public static Intent createClearPrimaryIntent(Context context, long dataId) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_CLEAR_PRIMARY); serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId); return serviceIntent; } private void clearPrimary(Intent intent) { long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1); if (dataId == -1) { Log.e(TAG, "Invalid arguments for clearPrimary request"); return; } // Update the primary values in the data record. ContentValues values = new ContentValues(1); values.put(Data.IS_SUPER_PRIMARY, 0); values.put(Data.IS_PRIMARY, 0); getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId), values, null, null); } /** * Creates an intent that can be sent to this service to delete a contact. */ public static Intent createDeleteContactIntent(Context context, Uri contactUri) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_DELETE_CONTACT); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri); return serviceIntent; } /** * Creates an intent that can be sent to this service to delete multiple contacts. */ public static Intent createDeleteMultipleContactsIntent(Context context, long[] contactIds, final String[] names) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_DELETE_MULTIPLE_CONTACTS); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds); serviceIntent.putExtra(ContactSaveService.EXTRA_DISPLAY_NAME_ARRAY, names); return serviceIntent; } private void deleteContact(Intent intent) { Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI); if (contactUri == null) { Log.e(TAG, "Invalid arguments for deleteContact request"); return; } getContentResolver().delete(contactUri, null, null); } private void deleteMultipleContacts(Intent intent) { final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS); if (contactIds == null) { Log.e(TAG, "Invalid arguments for deleteMultipleContacts request"); return; } for (long contactId : contactIds) { final Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId); getContentResolver().delete(contactUri, null, null); } final String[] names = intent.getStringArrayExtra( ContactSaveService.EXTRA_DISPLAY_NAME_ARRAY); final String deleteToastMessage; if (contactIds.length != names.length || names.length == 0) { MessageFormat msgFormat = new MessageFormat( getResources().getString(R.string.contacts_deleted_toast), Locale.getDefault()); Map arguments = new HashMap<>(); arguments.put("count", contactIds.length); deleteToastMessage = msgFormat.format(arguments); } else if (names.length == 1) { deleteToastMessage = getResources().getString( R.string.contacts_deleted_one_named_toast, (Object[]) names); } else if (names.length == 2) { deleteToastMessage = getResources().getString( R.string.contacts_deleted_two_named_toast, (Object[]) names); } else { deleteToastMessage = getResources().getString( R.string.contacts_deleted_many_named_toast, (Object[]) names); } mMainHandler.post(new Runnable() { @Override public void run() { Toast.makeText(ContactSaveService.this, deleteToastMessage, Toast.LENGTH_LONG) .show(); } }); } /** * Creates an intent that can be sent to this service to split a contact into it's constituent * pieces. This will set the raw contact ids to {@link AggregationExceptions#TYPE_AUTOMATIC} so * they may be re-merged by the auto-aggregator. */ public static Intent createSplitContactIntent(Context context, long[][] rawContactIds, ResultReceiver receiver) { final Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SPLIT_CONTACT); serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACT_IDS, rawContactIds); serviceIntent.putExtra(ContactSaveService.EXTRA_RESULT_RECEIVER, receiver); return serviceIntent; } /** * Creates an intent that can be sent to this service to split a contact into it's constituent * pieces. This will explicitly set the raw contact ids to * {@link AggregationExceptions#TYPE_KEEP_SEPARATE}. */ public static Intent createHardSplitContactIntent(Context context, long[][] rawContactIds) { final Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_SPLIT_CONTACT); serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACT_IDS, rawContactIds); serviceIntent.putExtra(ContactSaveService.EXTRA_HARD_SPLIT, true); return serviceIntent; } private void splitContact(Intent intent) { final long rawContactIds[][] = (long[][]) intent .getSerializableExtra(EXTRA_RAW_CONTACT_IDS); final ResultReceiver receiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER); final boolean hardSplit = intent.getBooleanExtra(EXTRA_HARD_SPLIT, false); if (rawContactIds == null) { Log.e(TAG, "Invalid argument for splitContact request"); if (receiver != null) { receiver.send(BAD_ARGUMENTS, new Bundle()); } return; } final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE; final ContentResolver resolver = getContentResolver(); final ArrayList operations = new ArrayList<>(batchSize); for (int i = 0; i < rawContactIds.length; i++) { for (int j = 0; j < rawContactIds.length; j++) { if (i != j) { if (!buildSplitTwoContacts(operations, rawContactIds[i], rawContactIds[j], hardSplit)) { if (receiver != null) { receiver.send(CP2_ERROR, new Bundle()); return; } } } } } if (operations.size() > 0 && !applyOperations(resolver, operations)) { if (receiver != null) { receiver.send(CP2_ERROR, new Bundle()); } return; } LocalBroadcastManager.getInstance(this) .sendBroadcast(new Intent(BROADCAST_UNLINK_COMPLETE)); if (receiver != null) { receiver.send(CONTACTS_SPLIT, new Bundle()); } else { showToast(R.string.contactUnlinkedToast); } } /** * Insert aggregation exception ContentProviderOperations between {@param rawContactIds1} * and {@param rawContactIds2} to {@param operations}. * @return false if an error occurred, true otherwise. */ private boolean buildSplitTwoContacts(ArrayList operations, long[] rawContactIds1, long[] rawContactIds2, boolean hardSplit) { if (rawContactIds1 == null || rawContactIds2 == null) { Log.e(TAG, "Invalid arguments for splitContact request"); return false; } // For each pair of raw contacts, insert an aggregation exception final ContentResolver resolver = getContentResolver(); // The maximum number of operations per batch (aka yield point) is 500. See b/22480225 final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE; for (int i = 0; i < rawContactIds1.length; i++) { for (int j = 0; j < rawContactIds2.length; j++) { buildSplitContactDiff(operations, rawContactIds1[i], rawContactIds2[j], hardSplit); // Before we get to 500 we need to flush the operations list if (operations.size() > 0 && operations.size() % batchSize == 0) { if (!applyOperations(resolver, operations)) { return false; } operations.clear(); } } } return true; } /** * Creates an intent that can be sent to this service to join two contacts. * The resulting contact uses the name from {@param contactId1} if possible. */ public static Intent createJoinContactsIntent(Context context, long contactId1, long contactId2, Class callbackActivity, String callbackAction) { Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_JOIN_CONTACTS); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID1, contactId1); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID2, contactId2); // Callback intent will be invoked by the service once the contacts are joined. Intent callbackIntent = new Intent(context, callbackActivity); callbackIntent.setAction(callbackAction); serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent); return serviceIntent; } /** * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts. * No special attention is paid to where the resulting contact's name is taken from. */ public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds, ResultReceiver receiver) { final Intent serviceIntent = new Intent(context, ContactSaveService.class); serviceIntent.setAction(ContactSaveService.ACTION_JOIN_SEVERAL_CONTACTS); serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds); serviceIntent.putExtra(ContactSaveService.EXTRA_RESULT_RECEIVER, receiver); return serviceIntent; } /** * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts. * No special attention is paid to where the resulting contact's name is taken from. */ public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds) { return createJoinSeveralContactsIntent(context, contactIds, /* receiver = */ null); } private interface JoinContactQuery { String[] PROJECTION = { RawContacts._ID, RawContacts.CONTACT_ID, RawContacts.DISPLAY_NAME_SOURCE, }; int _ID = 0; int CONTACT_ID = 1; int DISPLAY_NAME_SOURCE = 2; } private interface ContactEntityQuery { String[] PROJECTION = { Contacts.Entity.DATA_ID, Contacts.Entity.CONTACT_ID, Contacts.Entity.IS_SUPER_PRIMARY, }; String SELECTION = Data.MIMETYPE + " = '" + StructuredName.CONTENT_ITEM_TYPE + "'" + " AND " + StructuredName.DISPLAY_NAME + "=" + Contacts.DISPLAY_NAME + " AND " + StructuredName.DISPLAY_NAME + " IS NOT NULL " + " AND " + StructuredName.DISPLAY_NAME + " != '' "; int DATA_ID = 0; int CONTACT_ID = 1; int IS_SUPER_PRIMARY = 2; } private void joinSeveralContacts(Intent intent) { final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS); final ResultReceiver receiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER); // Load raw contact IDs for all contacts involved. final long rawContactIds[] = getRawContactIdsForAggregation(contactIds); final long[][] separatedRawContactIds = getSeparatedRawContactIds(contactIds); if (rawContactIds == null) { Log.e(TAG, "Invalid arguments for joinSeveralContacts request"); if (receiver != null) { receiver.send(BAD_ARGUMENTS, new Bundle()); } return; } // For each pair of raw contacts, insert an aggregation exception final ContentResolver resolver = getContentResolver(); // The maximum number of operations per batch (aka yield point) is 500. See b/22480225 final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE; final ArrayList operations = new ArrayList<>(batchSize); for (int i = 0; i < rawContactIds.length; i++) { for (int j = 0; j < rawContactIds.length; j++) { if (i != j) { buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]); } // Before we get to 500 we need to flush the operations list if (operations.size() > 0 && operations.size() % batchSize == 0) { if (!applyOperations(resolver, operations)) { if (receiver != null) { receiver.send(CP2_ERROR, new Bundle()); } return; } operations.clear(); } } } if (operations.size() > 0 && !applyOperations(resolver, operations)) { if (receiver != null) { receiver.send(CP2_ERROR, new Bundle()); } return; } final String name = queryNameOfLinkedContacts(contactIds); if (name != null) { if (receiver != null) { final Bundle result = new Bundle(); result.putSerializable(EXTRA_RAW_CONTACT_IDS, separatedRawContactIds); result.putString(EXTRA_DISPLAY_NAME, name); receiver.send(CONTACTS_LINKED, result); } else { if (TextUtils.isEmpty(name)) { showToast(R.string.contactsJoinedMessage); } else { showToast(R.string.contactsJoinedNamedMessage, name); } } LocalBroadcastManager.getInstance(this) .sendBroadcast(new Intent(BROADCAST_LINK_COMPLETE)); } else { if (receiver != null) { receiver.send(CP2_ERROR, new Bundle()); } showToast(R.string.contactJoinErrorToast); } } /** Get the display name of the top-level contact after the contacts have been linked. */ private String queryNameOfLinkedContacts(long[] contactIds) { final StringBuilder whereBuilder = new StringBuilder(Contacts._ID).append(" IN ("); final String[] whereArgs = new String[contactIds.length]; for (int i = 0; i < contactIds.length; i++) { whereArgs[i] = String.valueOf(contactIds[i]); whereBuilder.append("?,"); } whereBuilder.deleteCharAt(whereBuilder.length() - 1).append(')'); final Cursor cursor = getContentResolver().query(Contacts.CONTENT_URI, new String[]{Contacts._ID, Contacts.DISPLAY_NAME, Contacts.DISPLAY_NAME_ALTERNATIVE}, whereBuilder.toString(), whereArgs, null); String name = null; String nameAlt = null; long contactId = 0; try { if (cursor.moveToFirst()) { contactId = cursor.getLong(0); name = cursor.getString(1); nameAlt = cursor.getString(2); } while(cursor.moveToNext()) { if (cursor.getLong(0) != contactId) { return null; } } final String formattedName = ContactDisplayUtils.getPreferredDisplayName(name, nameAlt, new ContactsPreferences(getApplicationContext())); return formattedName == null ? "" : formattedName; } finally { if (cursor != null) { cursor.close(); } } } /** Returns true if the batch was successfully applied and false otherwise. */ private boolean applyOperations(ContentResolver resolver, ArrayList operations) { try { final ContentProviderResult[] result = resolver.applyBatch(ContactsContract.AUTHORITY, operations); for (int i = 0; i < result.length; ++i) { // if no rows were modified in the operation then we count it as fail. if (result[i].count < 0) { throw new OperationApplicationException(); } } return true; } catch (RemoteException | OperationApplicationException e) { FeedbackHelper.sendFeedback(this, TAG, "Failed to apply aggregation exception batch", e); showToast(R.string.contactSavedErrorToast); return false; } } private void joinContacts(Intent intent) { long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1); long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1); // Load raw contact IDs for all raw contacts involved - currently edited and selected // in the join UIs. long rawContactIds[] = getRawContactIdsForAggregation(contactId1, contactId2); if (rawContactIds == null) { Log.e(TAG, "Invalid arguments for joinContacts request"); return; } ArrayList operations = new ArrayList(); // For each pair of raw contacts, insert an aggregation exception for (int i = 0; i < rawContactIds.length; i++) { for (int j = 0; j < rawContactIds.length; j++) { if (i != j) { buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]); } } } final ContentResolver resolver = getContentResolver(); // Use the name for contactId1 as the name for the newly aggregated contact. final Uri contactId1Uri = ContentUris.withAppendedId( Contacts.CONTENT_URI, contactId1); final Uri entityUri = Uri.withAppendedPath( contactId1Uri, Contacts.Entity.CONTENT_DIRECTORY); Cursor c = resolver.query(entityUri, ContactEntityQuery.PROJECTION, ContactEntityQuery.SELECTION, null, null); if (c == null) { Log.e(TAG, "Unable to open Contacts DB cursor"); showToast(R.string.contactSavedErrorToast); return; } long dataIdToAddSuperPrimary = -1; try { if (c.moveToFirst()) { dataIdToAddSuperPrimary = c.getLong(ContactEntityQuery.DATA_ID); } } finally { c.close(); } // Mark the name from contactId1 IS_SUPER_PRIMARY to make sure that the contact // display name does not change as a result of the join. if (dataIdToAddSuperPrimary != -1) { Builder builder = ContentProviderOperation.newUpdate( ContentUris.withAppendedId(Data.CONTENT_URI, dataIdToAddSuperPrimary)); builder.withValue(Data.IS_SUPER_PRIMARY, 1); builder.withValue(Data.IS_PRIMARY, 1); operations.add(builder.build()); } // Apply all aggregation exceptions as one batch final boolean success = applyOperations(resolver, operations); final String name = queryNameOfLinkedContacts(new long[] {contactId1, contactId2}); Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT); if (success && name != null) { if (TextUtils.isEmpty(name)) { showToast(R.string.contactsJoinedMessage); } else { showToast(R.string.contactsJoinedNamedMessage, name); } Uri uri = RawContacts.getContactLookupUri(resolver, ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0])); callbackIntent.setData(uri); LocalBroadcastManager.getInstance(this) .sendBroadcast(new Intent(BROADCAST_LINK_COMPLETE)); } deliverCallback(callbackIntent); } /** * Gets the raw contact ids for each contact id in {@param contactIds}. Each index of the outer * array of the return value holds an array of raw contact ids for one contactId. * @param contactIds * @return */ private long[][] getSeparatedRawContactIds(long[] contactIds) { final long[][] rawContactIds = new long[contactIds.length][]; for (int i = 0; i < contactIds.length; i++) { rawContactIds[i] = getRawContactIds(contactIds[i]); } return rawContactIds; } /** * Gets the raw contact ids associated with {@param contactId}. * @param contactId * @return Array of raw contact ids. */ private long[] getRawContactIds(long contactId) { final ContentResolver resolver = getContentResolver(); long rawContactIds[]; final StringBuilder queryBuilder = new StringBuilder(); queryBuilder.append(RawContacts.CONTACT_ID) .append("=") .append(String.valueOf(contactId)); final Cursor c = resolver.query(RawContacts.CONTENT_URI, JoinContactQuery.PROJECTION, queryBuilder.toString(), null, null); if (c == null) { Log.e(TAG, "Unable to open Contacts DB cursor"); return null; } try { rawContactIds = new long[c.getCount()]; for (int i = 0; i < rawContactIds.length; i++) { c.moveToPosition(i); final long rawContactId = c.getLong(JoinContactQuery._ID); rawContactIds[i] = rawContactId; } } finally { c.close(); } return rawContactIds; } private long[] getRawContactIdsForAggregation(long[] contactIds) { if (contactIds == null) { return null; } final ContentResolver resolver = getContentResolver(); final StringBuilder queryBuilder = new StringBuilder(); final String stringContactIds[] = new String[contactIds.length]; for (int i = 0; i < contactIds.length; i++) { queryBuilder.append(RawContacts.CONTACT_ID + "=?"); stringContactIds[i] = String.valueOf(contactIds[i]); if (contactIds[i] == -1) { return null; } if (i == contactIds.length -1) { break; } queryBuilder.append(" OR "); } final Cursor c = resolver.query(RawContacts.CONTENT_URI, JoinContactQuery.PROJECTION, queryBuilder.toString(), stringContactIds, null); if (c == null) { Log.e(TAG, "Unable to open Contacts DB cursor"); showToast(R.string.contactSavedErrorToast); return null; } long rawContactIds[]; try { if (c.getCount() < 2) { Log.e(TAG, "Not enough raw contacts to aggregate together."); return null; } rawContactIds = new long[c.getCount()]; for (int i = 0; i < rawContactIds.length; i++) { c.moveToPosition(i); long rawContactId = c.getLong(JoinContactQuery._ID); rawContactIds[i] = rawContactId; } } finally { c.close(); } return rawContactIds; } private long[] getRawContactIdsForAggregation(long contactId1, long contactId2) { return getRawContactIdsForAggregation(new long[] {contactId1, contactId2}); } /** * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation. */ private void buildJoinContactDiff(ArrayList operations, long rawContactId1, long rawContactId2) { Builder builder = ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI); builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER); builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1); builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2); operations.add(builder.build()); } /** * Construct a {@link AggregationExceptions#TYPE_AUTOMATIC} or a * {@link AggregationExceptions#TYPE_KEEP_SEPARATE} ContentProviderOperation if a hard split is * requested. */ private void buildSplitContactDiff(ArrayList operations, long rawContactId1, long rawContactId2, boolean hardSplit) { final Builder builder = ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI); builder.withValue(AggregationExceptions.TYPE, hardSplit ? AggregationExceptions.TYPE_KEEP_SEPARATE : AggregationExceptions.TYPE_AUTOMATIC); builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1); builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2); operations.add(builder.build()); } /** * Returns an intent that can start this service and cause it to sleep for the specified time. * * This exists purely for debugging and manual testing. Since this service uses a single thread * it is useful to have a way to test behavior when work is queued up and most of the other * operations complete too quickly to simulate that under normal conditions. */ public static Intent createSleepIntent(Context context, long millis) { return new Intent(context, ContactSaveService.class).setAction(ACTION_SLEEP) .putExtra(EXTRA_SLEEP_DURATION, millis); } private void sleepForDebugging(Intent intent) { long duration = intent.getLongExtra(EXTRA_SLEEP_DURATION, 1000); if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "sleeping for " + duration + "ms"); } try { Thread.sleep(duration); } catch (InterruptedException e) { e.printStackTrace(); } if (Log.isLoggable(TAG, Log.DEBUG)) { Log.d(TAG, "finished sleeping"); } } /** * Shows a toast on the UI thread by formatting messageId using args. * @param messageId id of message string * @param args args to format string */ private void showToast(final int messageId, final Object... args) { final String message = getResources().getString(messageId, args); mMainHandler.post(new Runnable() { @Override public void run() { Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show(); } }); } /** * Shows a toast on the UI thread. */ private void showToast(final int message) { mMainHandler.post(new Runnable() { @Override public void run() { Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show(); } }); } private void deliverCallback(final Intent callbackIntent) { mMainHandler.post(new Runnable() { @Override public void run() { deliverCallbackOnUiThread(callbackIntent); } }); } void deliverCallbackOnUiThread(final Intent callbackIntent) { // TODO: this assumes that if there are multiple instances of the same // activity registered, the last one registered is the one waiting for // the callback. Validity of this assumption needs to be verified. for (Listener listener : sListeners) { if (callbackIntent.getComponent().equals( ((Activity) listener).getIntent().getComponent())) { listener.onServiceCompleted(callbackIntent); return; } } } public interface GroupsDao { Uri create(String title, AccountWithDataSet account); int delete(Uri groupUri); Bundle captureDeletionUndoData(Uri groupUri); Uri undoDeletion(Bundle undoData); } public static class GroupsDaoImpl implements GroupsDao { public static final String KEY_GROUP_DATA = "groupData"; public static final String KEY_GROUP_MEMBERS = "groupMemberIds"; private static final String TAG = "GroupsDao"; private final Context context; private final ContentResolver contentResolver; public GroupsDaoImpl(Context context) { this(context, context.getContentResolver()); } public GroupsDaoImpl(Context context, ContentResolver contentResolver) { this.context = context; this.contentResolver = contentResolver; } public Bundle captureDeletionUndoData(Uri groupUri) { final long groupId = ContentUris.parseId(groupUri); final Bundle result = new Bundle(); final Cursor cursor = contentResolver.query(groupUri, new String[]{ Groups.TITLE, Groups.NOTES, Groups.GROUP_VISIBLE, Groups.ACCOUNT_TYPE, Groups.ACCOUNT_NAME, Groups.DATA_SET, Groups.SHOULD_SYNC }, Groups.DELETED + "=?", new String[] { "0" }, null); try { if (cursor.moveToFirst()) { final ContentValues groupValues = new ContentValues(); DatabaseUtils.cursorRowToContentValues(cursor, groupValues); result.putParcelable(KEY_GROUP_DATA, groupValues); } else { // Group doesn't exist. return result; } } finally { cursor.close(); } final Cursor membersCursor = contentResolver.query( Data.CONTENT_URI, new String[] { Data.RAW_CONTACT_ID }, Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?", new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) }, null); final long[] memberIds = new long[membersCursor.getCount()]; int i = 0; while (membersCursor.moveToNext()) { memberIds[i++] = membersCursor.getLong(0); } result.putLongArray(KEY_GROUP_MEMBERS, memberIds); return result; } public Uri undoDeletion(Bundle deletedGroupData) { final ContentValues groupData = deletedGroupData.getParcelable(KEY_GROUP_DATA); if (groupData == null) { return null; } final Uri groupUri = contentResolver.insert(Groups.CONTENT_URI, groupData); final long groupId = ContentUris.parseId(groupUri); final long[] memberIds = deletedGroupData.getLongArray(KEY_GROUP_MEMBERS); if (memberIds == null) { return groupUri; } final ContentValues[] memberInsertions = new ContentValues[memberIds.length]; for (int i = 0; i < memberIds.length; i++) { memberInsertions[i] = new ContentValues(); memberInsertions[i].put(Data.RAW_CONTACT_ID, memberIds[i]); memberInsertions[i].put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE); memberInsertions[i].put(GroupMembership.GROUP_ROW_ID, groupId); } final int inserted = contentResolver.bulkInsert(Data.CONTENT_URI, memberInsertions); if (inserted != memberIds.length) { Log.e(TAG, "Could not recover some members for group deletion undo"); } return groupUri; } public Uri create(String title, AccountWithDataSet account) { final ContentValues values = new ContentValues(); values.put(Groups.TITLE, title); values.put(Groups.ACCOUNT_NAME, account.name); values.put(Groups.ACCOUNT_TYPE, account.type); values.put(Groups.DATA_SET, account.dataSet); return contentResolver.insert(Groups.CONTENT_URI, values); } public int delete(Uri groupUri) { return contentResolver.delete(groupUri, null, null); } } /** * Keeps track of which operations have been requested but have not yet finished for this * service. */ public static class State { private final CopyOnWriteArrayList mPending; public State() { mPending = new CopyOnWriteArrayList<>(); } public State(Collection pendingActions) { mPending = new CopyOnWriteArrayList<>(pendingActions); } public boolean isIdle() { return mPending.isEmpty(); } public Intent getCurrentIntent() { return mPending.isEmpty() ? null : mPending.get(0); } /** * Returns the first intent requested that has the specified action or null if no intent * with that action has been requested. */ public Intent getNextIntentWithAction(String action) { for (Intent intent : mPending) { if (action.equals(intent.getAction())) { return intent; } } return null; } public boolean isActionPending(String action) { return getNextIntentWithAction(action) != null; } private void onFinish(Intent intent) { if (mPending.isEmpty()) { return; } final String action = mPending.get(0).getAction(); if (action.equals(intent.getAction())) { mPending.remove(0); } } private void onStart(Intent intent) { if (intent.getAction() == null) { return; } mPending.add(intent); } } }