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 
17 package com.android.contacts;
18 
19 import static android.Manifest.permission.WRITE_CONTACTS;
20 
21 import android.app.Activity;
22 import android.app.IntentService;
23 import android.content.ContentProviderOperation;
24 import android.content.ContentProviderOperation.Builder;
25 import android.content.ContentProviderResult;
26 import android.content.ContentResolver;
27 import android.content.ContentUris;
28 import android.content.ContentValues;
29 import android.content.Context;
30 import android.content.Intent;
31 import android.content.OperationApplicationException;
32 import android.database.Cursor;
33 import android.database.DatabaseUtils;
34 import android.net.Uri;
35 import android.os.Bundle;
36 import android.os.Handler;
37 import android.os.Looper;
38 import android.os.Parcelable;
39 import android.os.RemoteException;
40 import android.provider.ContactsContract;
41 import android.provider.ContactsContract.AggregationExceptions;
42 import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
43 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
44 import android.provider.ContactsContract.Contacts;
45 import android.provider.ContactsContract.Data;
46 import android.provider.ContactsContract.Groups;
47 import android.provider.ContactsContract.Profile;
48 import android.provider.ContactsContract.RawContacts;
49 import android.provider.ContactsContract.RawContactsEntity;
50 import androidx.localbroadcastmanager.content.LocalBroadcastManager;
51 import android.support.v4.os.ResultReceiver;
52 import android.text.TextUtils;
53 import android.util.Log;
54 import android.widget.Toast;
55 
56 import com.android.contacts.activities.ContactEditorActivity;
57 import com.android.contacts.compat.CompatUtils;
58 import com.android.contacts.compat.PinnedPositionsCompat;
59 import com.android.contacts.database.ContactUpdateUtils;
60 import com.android.contacts.database.SimContactDao;
61 import com.android.contacts.model.AccountTypeManager;
62 import com.android.contacts.model.CPOWrapper;
63 import com.android.contacts.model.RawContactDelta;
64 import com.android.contacts.model.RawContactDeltaList;
65 import com.android.contacts.model.RawContactModifier;
66 import com.android.contacts.model.account.AccountWithDataSet;
67 import com.android.contacts.preference.ContactsPreferences;
68 import com.android.contacts.util.ContactDisplayUtils;
69 import com.android.contacts.util.ContactPhotoUtils;
70 import com.android.contacts.util.PermissionsUtil;
71 import com.android.contactsbind.FeedbackHelper;
72 
73 import com.google.common.collect.Lists;
74 import com.google.common.collect.Sets;
75 
76 import java.util.ArrayList;
77 import java.util.Collection;
78 import java.util.HashSet;
79 import java.util.List;
80 import java.util.concurrent.CopyOnWriteArrayList;
81 
82 /**
83  * A service responsible for saving changes to the content provider.
84  */
85 public class ContactSaveService extends IntentService {
86     private static final String TAG = "ContactSaveService";
87 
88     /** Set to true in order to view logs on content provider operations */
89     private static final boolean DEBUG = false;
90 
91     public static final String ACTION_NEW_RAW_CONTACT = "newRawContact";
92 
93     public static final String EXTRA_ACCOUNT_NAME = "accountName";
94     public static final String EXTRA_ACCOUNT_TYPE = "accountType";
95     public static final String EXTRA_DATA_SET = "dataSet";
96     public static final String EXTRA_ACCOUNT = "account";
97     public static final String EXTRA_CONTENT_VALUES = "contentValues";
98     public static final String EXTRA_CALLBACK_INTENT = "callbackIntent";
99     public static final String EXTRA_RESULT_RECEIVER = "resultReceiver";
100     public static final String EXTRA_RAW_CONTACT_IDS = "rawContactIds";
101 
102     public static final String ACTION_SAVE_CONTACT = "saveContact";
103     public static final String EXTRA_CONTACT_STATE = "state";
104     public static final String EXTRA_SAVE_MODE = "saveMode";
105     public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile";
106     public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded";
107     public static final String EXTRA_UPDATED_PHOTOS = "updatedPhotos";
108 
109     public static final String ACTION_CREATE_GROUP = "createGroup";
110     public static final String ACTION_RENAME_GROUP = "renameGroup";
111     public static final String ACTION_DELETE_GROUP = "deleteGroup";
112     public static final String ACTION_UPDATE_GROUP = "updateGroup";
113     public static final String EXTRA_GROUP_ID = "groupId";
114     public static final String EXTRA_GROUP_LABEL = "groupLabel";
115     public static final String EXTRA_RAW_CONTACTS_TO_ADD = "rawContactsToAdd";
116     public static final String EXTRA_RAW_CONTACTS_TO_REMOVE = "rawContactsToRemove";
117 
118     public static final String ACTION_SET_STARRED = "setStarred";
119     public static final String ACTION_DELETE_CONTACT = "delete";
120     public static final String ACTION_DELETE_MULTIPLE_CONTACTS = "deleteMultipleContacts";
121     public static final String EXTRA_CONTACT_URI = "contactUri";
122     public static final String EXTRA_CONTACT_IDS = "contactIds";
123     public static final String EXTRA_STARRED_FLAG = "starred";
124     public static final String EXTRA_DISPLAY_NAME = "extraDisplayName";
125     public static final String EXTRA_DISPLAY_NAME_ARRAY = "extraDisplayNameArray";
126 
127     public static final String ACTION_SET_SUPER_PRIMARY = "setSuperPrimary";
128     public static final String ACTION_CLEAR_PRIMARY = "clearPrimary";
129     public static final String EXTRA_DATA_ID = "dataId";
130 
131     public static final String ACTION_SPLIT_CONTACT = "splitContact";
132     public static final String EXTRA_HARD_SPLIT = "extraHardSplit";
133 
134     public static final String ACTION_JOIN_CONTACTS = "joinContacts";
135     public static final String ACTION_JOIN_SEVERAL_CONTACTS = "joinSeveralContacts";
136     public static final String EXTRA_CONTACT_ID1 = "contactId1";
137     public static final String EXTRA_CONTACT_ID2 = "contactId2";
138 
139     public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail";
140     public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag";
141 
142     public static final String ACTION_SET_RINGTONE = "setRingtone";
143     public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone";
144 
145     public static final String ACTION_UNDO = "undo";
146     public static final String EXTRA_UNDO_ACTION = "undoAction";
147     public static final String EXTRA_UNDO_DATA = "undoData";
148 
149     // For debugging and testing what happens when requests are queued up.
150     public static final String ACTION_SLEEP = "sleep";
151     public static final String EXTRA_SLEEP_DURATION = "sleepDuration";
152 
153     public static final String BROADCAST_GROUP_DELETED = "groupDeleted";
154     public static final String BROADCAST_LINK_COMPLETE = "linkComplete";
155     public static final String BROADCAST_UNLINK_COMPLETE = "unlinkComplete";
156 
157     public static final String BROADCAST_SERVICE_STATE_CHANGED = "serviceStateChanged";
158 
159     public static final String EXTRA_RESULT_CODE = "resultCode";
160     public static final String EXTRA_RESULT_COUNT = "count";
161 
162     public static final int CP2_ERROR = 0;
163     public static final int CONTACTS_LINKED = 1;
164     public static final int CONTACTS_SPLIT = 2;
165     public static final int BAD_ARGUMENTS = 3;
166     public static final int RESULT_UNKNOWN = 0;
167     public static final int RESULT_SUCCESS = 1;
168     public static final int RESULT_FAILURE = 2;
169 
170     private static final HashSet<String> ALLOWED_DATA_COLUMNS = Sets.newHashSet(
171         Data.MIMETYPE,
172         Data.IS_PRIMARY,
173         Data.DATA1,
174         Data.DATA2,
175         Data.DATA3,
176         Data.DATA4,
177         Data.DATA5,
178         Data.DATA6,
179         Data.DATA7,
180         Data.DATA8,
181         Data.DATA9,
182         Data.DATA10,
183         Data.DATA11,
184         Data.DATA12,
185         Data.DATA13,
186         Data.DATA14,
187         Data.DATA15
188     );
189 
190     private static final int PERSIST_TRIES = 3;
191 
192     private static final int MAX_CONTACTS_PROVIDER_BATCH_SIZE = 499;
193 
194     public interface Listener {
onServiceCompleted(Intent callbackIntent)195         public void onServiceCompleted(Intent callbackIntent);
196     }
197 
198     private static final CopyOnWriteArrayList<Listener> sListeners =
199             new CopyOnWriteArrayList<Listener>();
200 
201     // Holds the current state of the service
202     private static final State sState = new State();
203 
204     private Handler mMainHandler;
205     private GroupsDao mGroupsDao;
206     private SimContactDao mSimContactDao;
207 
ContactSaveService()208     public ContactSaveService() {
209         super(TAG);
210         setIntentRedelivery(true);
211         mMainHandler = new Handler(Looper.getMainLooper());
212     }
213 
214     @Override
onCreate()215     public void onCreate() {
216         super.onCreate();
217         mGroupsDao = new GroupsDaoImpl(this);
218         mSimContactDao = SimContactDao.create(this);
219     }
220 
registerListener(Listener listener)221     public static void registerListener(Listener listener) {
222         if (!(listener instanceof Activity)) {
223             throw new ClassCastException("Only activities can be registered to"
224                     + " receive callback from " + ContactSaveService.class.getName());
225         }
226         sListeners.add(0, listener);
227     }
228 
canUndo(Intent resultIntent)229     public static boolean canUndo(Intent resultIntent) {
230         return resultIntent.hasExtra(EXTRA_UNDO_DATA);
231     }
232 
unregisterListener(Listener listener)233     public static void unregisterListener(Listener listener) {
234         sListeners.remove(listener);
235     }
236 
getState()237     public static State getState() {
238         return sState;
239     }
240 
notifyStateChanged()241     private void notifyStateChanged() {
242         LocalBroadcastManager.getInstance(this)
243                 .sendBroadcast(new Intent(BROADCAST_SERVICE_STATE_CHANGED));
244     }
245 
246     /**
247      * Returns true if the ContactSaveService was started successfully and false if an exception
248      * was thrown and a Toast error message was displayed.
249      */
startService(Context context, Intent intent, int saveMode)250     public static boolean startService(Context context, Intent intent, int saveMode) {
251         try {
252             context.startService(intent);
253         } catch (Exception exception) {
254             final int resId;
255             switch (saveMode) {
256                 case ContactEditorActivity.ContactEditor.SaveMode.SPLIT:
257                     resId = R.string.contactUnlinkErrorToast;
258                     break;
259                 case ContactEditorActivity.ContactEditor.SaveMode.RELOAD:
260                     resId = R.string.contactJoinErrorToast;
261                     break;
262                 case ContactEditorActivity.ContactEditor.SaveMode.CLOSE:
263                     resId = R.string.contactSavedErrorToast;
264                     break;
265                 default:
266                     resId = R.string.contactGenericErrorToast;
267             }
268             Toast.makeText(context, resId, Toast.LENGTH_SHORT).show();
269             return false;
270         }
271         return true;
272     }
273 
274     /**
275      * Utility method that starts service and handles exception.
276      */
startService(Context context, Intent intent)277     public static void startService(Context context, Intent intent) {
278         try {
279             context.startService(intent);
280         } catch (Exception exception) {
281             Toast.makeText(context, R.string.contactGenericErrorToast, Toast.LENGTH_SHORT).show();
282         }
283     }
284 
285     @Override
getSystemService(String name)286     public Object getSystemService(String name) {
287         Object service = super.getSystemService(name);
288         if (service != null) {
289             return service;
290         }
291 
292         return getApplicationContext().getSystemService(name);
293     }
294 
295     // Parent classes Javadoc says not to override this method but we're doing it just to update
296     // our state which should be OK since we're still doing the work in onHandleIntent
297     @Override
onStartCommand(Intent intent, int flags, int startId)298     public int onStartCommand(Intent intent, int flags, int startId) {
299         sState.onStart(intent);
300         notifyStateChanged();
301         return super.onStartCommand(intent, flags, startId);
302     }
303 
304     @Override
onHandleIntent(final Intent intent)305     protected void onHandleIntent(final Intent intent) {
306         if (intent == null) {
307             if (Log.isLoggable(TAG, Log.DEBUG)) {
308                 Log.d(TAG, "onHandleIntent: could not handle null intent");
309             }
310             return;
311         }
312         if (!PermissionsUtil.hasPermission(this, WRITE_CONTACTS)) {
313             Log.w(TAG, "No WRITE_CONTACTS permission, unable to write to CP2");
314             // TODO: add more specific error string such as "Turn on Contacts
315             // permission to update your contacts"
316             showToast(R.string.contactSavedErrorToast);
317             return;
318         }
319 
320         // Call an appropriate method. If we're sure it affects how incoming phone calls are
321         // handled, then notify the fact to in-call screen.
322         String action = intent.getAction();
323         if (ACTION_NEW_RAW_CONTACT.equals(action)) {
324             createRawContact(intent);
325         } else if (ACTION_SAVE_CONTACT.equals(action)) {
326             saveContact(intent);
327         } else if (ACTION_CREATE_GROUP.equals(action)) {
328             createGroup(intent);
329         } else if (ACTION_RENAME_GROUP.equals(action)) {
330             renameGroup(intent);
331         } else if (ACTION_DELETE_GROUP.equals(action)) {
332             deleteGroup(intent);
333         } else if (ACTION_UPDATE_GROUP.equals(action)) {
334             updateGroup(intent);
335         } else if (ACTION_SET_STARRED.equals(action)) {
336             setStarred(intent);
337         } else if (ACTION_SET_SUPER_PRIMARY.equals(action)) {
338             setSuperPrimary(intent);
339         } else if (ACTION_CLEAR_PRIMARY.equals(action)) {
340             clearPrimary(intent);
341         } else if (ACTION_DELETE_MULTIPLE_CONTACTS.equals(action)) {
342             deleteMultipleContacts(intent);
343         } else if (ACTION_DELETE_CONTACT.equals(action)) {
344             deleteContact(intent);
345         } else if (ACTION_SPLIT_CONTACT.equals(action)) {
346             splitContact(intent);
347         } else if (ACTION_JOIN_CONTACTS.equals(action)) {
348             joinContacts(intent);
349         } else if (ACTION_JOIN_SEVERAL_CONTACTS.equals(action)) {
350             joinSeveralContacts(intent);
351         } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) {
352             setSendToVoicemail(intent);
353         } else if (ACTION_SET_RINGTONE.equals(action)) {
354             setRingtone(intent);
355         } else if (ACTION_UNDO.equals(action)) {
356             undo(intent);
357         } else if (ACTION_SLEEP.equals(action)) {
358             sleepForDebugging(intent);
359         }
360 
361         sState.onFinish(intent);
362         notifyStateChanged();
363     }
364 
365     /**
366      * Creates an intent that can be sent to this service to create a new raw contact
367      * using data presented as a set of ContentValues.
368      */
createNewRawContactIntent(Context context, ArrayList<ContentValues> values, AccountWithDataSet account, Class<? extends Activity> callbackActivity, String callbackAction)369     public static Intent createNewRawContactIntent(Context context,
370             ArrayList<ContentValues> values, AccountWithDataSet account,
371             Class<? extends Activity> callbackActivity, String callbackAction) {
372         Intent serviceIntent = new Intent(
373                 context, ContactSaveService.class);
374         serviceIntent.setAction(ContactSaveService.ACTION_NEW_RAW_CONTACT);
375         if (account != null) {
376             serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
377             serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
378             serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
379         }
380         serviceIntent.putParcelableArrayListExtra(
381                 ContactSaveService.EXTRA_CONTENT_VALUES, values);
382 
383         // Callback intent will be invoked by the service once the new contact is
384         // created.  The service will put the URI of the new contact as "data" on
385         // the callback intent.
386         Intent callbackIntent = new Intent(context, callbackActivity);
387         callbackIntent.setAction(callbackAction);
388         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
389         return serviceIntent;
390     }
391 
createRawContact(Intent intent)392     private void createRawContact(Intent intent) {
393         String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
394         String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
395         String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
396         List<ContentValues> valueList = intent.getParcelableArrayListExtra(EXTRA_CONTENT_VALUES);
397         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
398 
399         ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
400         operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
401                 .withValue(RawContacts.ACCOUNT_NAME, accountName)
402                 .withValue(RawContacts.ACCOUNT_TYPE, accountType)
403                 .withValue(RawContacts.DATA_SET, dataSet)
404                 .build());
405 
406         int size = valueList.size();
407         for (int i = 0; i < size; i++) {
408             ContentValues values = valueList.get(i);
409             values.keySet().retainAll(ALLOWED_DATA_COLUMNS);
410             operations.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
411                     .withValueBackReference(Data.RAW_CONTACT_ID, 0)
412                     .withValues(values)
413                     .build());
414         }
415 
416         ContentResolver resolver = getContentResolver();
417         ContentProviderResult[] results;
418         try {
419             results = resolver.applyBatch(ContactsContract.AUTHORITY, operations);
420         } catch (Exception e) {
421             throw new RuntimeException("Failed to store new contact", e);
422         }
423 
424         Uri rawContactUri = results[0].uri;
425         callbackIntent.setData(RawContacts.getContactLookupUri(resolver, rawContactUri));
426 
427         deliverCallback(callbackIntent);
428     }
429 
430     /**
431      * Creates an intent that can be sent to this service to create a new raw contact
432      * using data presented as a set of ContentValues.
433      * This variant is more convenient to use when there is only one photo that can
434      * possibly be updated, as in the Contact Details screen.
435      * @param rawContactId identifies a writable raw-contact whose photo is to be updated.
436      * @param updatedPhotoPath denotes a temporary file containing the contact's new photo.
437      */
createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class<? extends Activity> callbackActivity, String callbackAction, long rawContactId, Uri updatedPhotoPath)438     public static Intent createSaveContactIntent(Context context, RawContactDeltaList state,
439             String saveModeExtraKey, int saveMode, boolean isProfile,
440             Class<? extends Activity> callbackActivity, String callbackAction, long rawContactId,
441             Uri updatedPhotoPath) {
442         Bundle bundle = new Bundle();
443         bundle.putParcelable(String.valueOf(rawContactId), updatedPhotoPath);
444         return createSaveContactIntent(context, state, saveModeExtraKey, saveMode, isProfile,
445                 callbackActivity, callbackAction, bundle,
446                 /* joinContactIdExtraKey */ null, /* joinContactId */ null);
447     }
448 
449     /**
450      * Creates an intent that can be sent to this service to create a new raw contact
451      * using data presented as a set of ContentValues.
452      * This variant is used when multiple contacts' photos may be updated, as in the
453      * Contact Editor.
454      *
455      * @param updatedPhotos maps each raw-contact's ID to the file-path of the new photo.
456      * @param joinContactIdExtraKey the key used to pass the joinContactId in the callback intent.
457      * @param joinContactId the raw contact ID to join to the contact after doing the save.
458      */
createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class<? extends Activity> callbackActivity, String callbackAction, Bundle updatedPhotos, String joinContactIdExtraKey, Long joinContactId)459     public static Intent createSaveContactIntent(Context context, RawContactDeltaList state,
460             String saveModeExtraKey, int saveMode, boolean isProfile,
461             Class<? extends Activity> callbackActivity, String callbackAction,
462             Bundle updatedPhotos, String joinContactIdExtraKey, Long joinContactId) {
463         Intent serviceIntent = new Intent(
464                 context, ContactSaveService.class);
465         serviceIntent.setAction(ContactSaveService.ACTION_SAVE_CONTACT);
466         serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state);
467         serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile);
468         serviceIntent.putExtra(EXTRA_SAVE_MODE, saveMode);
469 
470         if (updatedPhotos != null) {
471             serviceIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos);
472         }
473 
474         if (callbackActivity != null) {
475             // Callback intent will be invoked by the service once the contact is
476             // saved.  The service will put the URI of the new contact as "data" on
477             // the callback intent.
478             Intent callbackIntent = new Intent(context, callbackActivity);
479             callbackIntent.putExtra(saveModeExtraKey, saveMode);
480             if (joinContactIdExtraKey != null && joinContactId != null) {
481                 callbackIntent.putExtra(joinContactIdExtraKey, joinContactId);
482             }
483             callbackIntent.setAction(callbackAction);
484             serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
485         }
486         return serviceIntent;
487     }
488 
saveContact(Intent intent)489     private void saveContact(Intent intent) {
490         RawContactDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE);
491         boolean isProfile = intent.getBooleanExtra(EXTRA_SAVE_IS_PROFILE, false);
492         Bundle updatedPhotos = intent.getParcelableExtra(EXTRA_UPDATED_PHOTOS);
493 
494         if (state == null) {
495             Log.e(TAG, "Invalid arguments for saveContact request");
496             return;
497         }
498 
499         int saveMode = intent.getIntExtra(EXTRA_SAVE_MODE, -1);
500         // Trim any empty fields, and RawContacts, before persisting
501         final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
502         RawContactModifier.trimEmpty(state, accountTypes);
503 
504         Uri lookupUri = null;
505 
506         final ContentResolver resolver = getContentResolver();
507 
508         boolean succeeded = false;
509 
510         // Keep track of the id of a newly raw-contact (if any... there can be at most one).
511         long insertedRawContactId = -1;
512 
513         // Attempt to persist changes
514         int tries = 0;
515         while (tries++ < PERSIST_TRIES) {
516             try {
517                 // Build operations and try applying
518                 final ArrayList<CPOWrapper> diffWrapper = state.buildDiffWrapper();
519 
520                 final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
521 
522                 for (CPOWrapper cpoWrapper : diffWrapper) {
523                     diff.add(cpoWrapper.getOperation());
524                 }
525 
526                 if (DEBUG) {
527                     Log.v(TAG, "Content Provider Operations:");
528                     for (ContentProviderOperation operation : diff) {
529                         Log.v(TAG, operation.toString());
530                     }
531                 }
532 
533                 int numberProcessed = 0;
534                 boolean batchFailed = false;
535                 final ContentProviderResult[] results = new ContentProviderResult[diff.size()];
536                 while (numberProcessed < diff.size()) {
537                     final int subsetCount = applyDiffSubset(diff, numberProcessed, results, resolver);
538                     if (subsetCount == -1) {
539                         Log.w(TAG, "Resolver.applyBatch failed in saveContacts");
540                         batchFailed = true;
541                         break;
542                     } else {
543                         numberProcessed += subsetCount;
544                     }
545                 }
546 
547                 if (batchFailed) {
548                     // Retry save
549                     continue;
550                 }
551 
552                 final long rawContactId = getRawContactId(state, diffWrapper, results);
553                 if (rawContactId == -1) {
554                     throw new IllegalStateException("Could not determine RawContact ID after save");
555                 }
556                 // We don't have to check to see if the value is still -1.  If we reach here,
557                 // the previous loop iteration didn't succeed, so any ID that we obtained is bogus.
558                 insertedRawContactId = getInsertedRawContactId(diffWrapper, results);
559                 if (isProfile) {
560                     // Since the profile supports local raw contacts, which may have been completely
561                     // removed if all information was removed, we need to do a special query to
562                     // get the lookup URI for the profile contact (if it still exists).
563                     Cursor c = resolver.query(Profile.CONTENT_URI,
564                             new String[] {Contacts._ID, Contacts.LOOKUP_KEY},
565                             null, null, null);
566                     if (c == null) {
567                         continue;
568                     }
569                     try {
570                         if (c.moveToFirst()) {
571                             final long contactId = c.getLong(0);
572                             final String lookupKey = c.getString(1);
573                             lookupUri = Contacts.getLookupUri(contactId, lookupKey);
574                         }
575                     } finally {
576                         c.close();
577                     }
578                 } else {
579                     final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI,
580                                     rawContactId);
581                     lookupUri = RawContacts.getContactLookupUri(resolver, rawContactUri);
582                 }
583                 if (lookupUri != null && Log.isLoggable(TAG, Log.VERBOSE)) {
584                     Log.v(TAG, "Saved contact. New URI: " + lookupUri);
585                 }
586 
587                 // We can change this back to false later, if we fail to save the contact photo.
588                 succeeded = true;
589                 break;
590 
591             } catch (RemoteException e) {
592                 // Something went wrong, bail without success
593                 FeedbackHelper.sendFeedback(this, TAG, "Problem persisting user edits", e);
594                 break;
595 
596             } catch (IllegalArgumentException e) {
597                 // This is thrown by applyBatch on malformed requests
598                 FeedbackHelper.sendFeedback(this, TAG, "Problem persisting user edits", e);
599                 showToast(R.string.contactSavedErrorToast);
600                 break;
601 
602             } catch (OperationApplicationException e) {
603                 // Version consistency failed, re-parent change and try again
604                 Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString());
605                 final StringBuilder sb = new StringBuilder(RawContacts._ID + " IN(");
606                 boolean first = true;
607                 final int count = state.size();
608                 for (int i = 0; i < count; i++) {
609                     Long rawContactId = state.getRawContactId(i);
610                     if (rawContactId != null && rawContactId != -1) {
611                         if (!first) {
612                             sb.append(',');
613                         }
614                         sb.append(rawContactId);
615                         first = false;
616                     }
617                 }
618                 sb.append(")");
619 
620                 if (first) {
621                     throw new IllegalStateException(
622                             "Version consistency failed for a new contact", e);
623                 }
624 
625                 final RawContactDeltaList newState = RawContactDeltaList.fromQuery(
626                         isProfile
627                                 ? RawContactsEntity.PROFILE_CONTENT_URI
628                                 : RawContactsEntity.CONTENT_URI,
629                         resolver, sb.toString(), null, null);
630                 state = RawContactDeltaList.mergeAfter(newState, state);
631 
632                 // Update the new state to use profile URIs if appropriate.
633                 if (isProfile) {
634                     for (RawContactDelta delta : state) {
635                         delta.setProfileQueryUri();
636                     }
637                 }
638             }
639         }
640 
641         // Now save any updated photos.  We do this at the end to ensure that
642         // the ContactProvider already knows about newly-created contacts.
643         if (updatedPhotos != null) {
644             for (String key : updatedPhotos.keySet()) {
645                 Uri photoUri = updatedPhotos.getParcelable(key);
646                 long rawContactId = Long.parseLong(key);
647 
648                 // If the raw-contact ID is negative, we are saving a new raw-contact;
649                 // replace the bogus ID with the new one that we actually saved the contact at.
650                 if (rawContactId < 0) {
651                     rawContactId = insertedRawContactId;
652                 }
653 
654                 // If the save failed, insertedRawContactId will be -1
655                 if (rawContactId < 0 || !saveUpdatedPhoto(rawContactId, photoUri, saveMode)) {
656                     succeeded = false;
657                 }
658             }
659         }
660 
661         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
662         if (callbackIntent != null) {
663             if (succeeded) {
664                 // Mark the intent to indicate that the save was successful (even if the lookup URI
665                 // is now null).  For local contacts or the local profile, it's possible that the
666                 // save triggered removal of the contact, so no lookup URI would exist..
667                 callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true);
668             }
669             callbackIntent.setData(lookupUri);
670             deliverCallback(callbackIntent);
671         }
672     }
673 
674     /**
675      * Splits "diff" into subsets based on "MAX_CONTACTS_PROVIDER_BATCH_SIZE", applies each of the
676      * subsets, adds the returned array to "results".
677      *
678      * @return the size of the array, if not null; -1 when the array is null.
679      */
applyDiffSubset(ArrayList<ContentProviderOperation> diff, int offset, ContentProviderResult[] results, ContentResolver resolver)680     private int applyDiffSubset(ArrayList<ContentProviderOperation> diff, int offset,
681             ContentProviderResult[] results, ContentResolver resolver)
682             throws RemoteException, OperationApplicationException {
683         final int subsetCount = Math.min(diff.size() - offset, MAX_CONTACTS_PROVIDER_BATCH_SIZE);
684         final ArrayList<ContentProviderOperation> subset = new ArrayList<>();
685         subset.addAll(diff.subList(offset, offset + subsetCount));
686         final ContentProviderResult[] subsetResult = resolver.applyBatch(ContactsContract
687                 .AUTHORITY, subset);
688         if (subsetResult == null || (offset + subsetResult.length) > results.length) {
689             return -1;
690         }
691         for (ContentProviderResult c : subsetResult) {
692             results[offset++] = c;
693         }
694         return subsetResult.length;
695     }
696 
697     /**
698      * Save updated photo for the specified raw-contact.
699      * @return true for success, false for failure
700      */
saveUpdatedPhoto(long rawContactId, Uri photoUri, int saveMode)701     private boolean saveUpdatedPhoto(long rawContactId, Uri photoUri, int saveMode) {
702         final Uri outputUri = Uri.withAppendedPath(
703                 ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
704                 RawContacts.DisplayPhoto.CONTENT_DIRECTORY);
705 
706         return ContactPhotoUtils.savePhotoFromUriToUri(this, photoUri, outputUri, (saveMode == 0));
707     }
708 
709     /**
710      * Find the ID of an existing or newly-inserted raw-contact.  If none exists, return -1.
711      */
getRawContactId(RawContactDeltaList state, final ArrayList<CPOWrapper> diffWrapper, final ContentProviderResult[] results)712     private long getRawContactId(RawContactDeltaList state,
713             final ArrayList<CPOWrapper> diffWrapper,
714             final ContentProviderResult[] results) {
715         long existingRawContactId = state.findRawContactId();
716         if (existingRawContactId != -1) {
717             return existingRawContactId;
718         }
719 
720         return getInsertedRawContactId(diffWrapper, results);
721     }
722 
723     /**
724      * Find the ID of a newly-inserted raw-contact.  If none exists, return -1.
725      */
getInsertedRawContactId( final ArrayList<CPOWrapper> diffWrapper, final ContentProviderResult[] results)726     private long getInsertedRawContactId(
727             final ArrayList<CPOWrapper> diffWrapper, final ContentProviderResult[] results) {
728         if (results == null) {
729             return -1;
730         }
731         final int diffSize = diffWrapper.size();
732         final int numResults = results.length;
733         for (int i = 0; i < diffSize && i < numResults; i++) {
734             final CPOWrapper cpoWrapper = diffWrapper.get(i);
735             final boolean isInsert = CompatUtils.isInsertCompat(cpoWrapper);
736             if (isInsert && cpoWrapper.getOperation().getUri().getEncodedPath().contains(
737                     RawContacts.CONTENT_URI.getEncodedPath())) {
738                 return ContentUris.parseId(results[i].uri);
739             }
740         }
741         return -1;
742     }
743 
744     /**
745      * Creates an intent that can be sent to this service to create a new group as
746      * well as add new members at the same time.
747      *
748      * @param context of the application
749      * @param account in which the group should be created
750      * @param label is the name of the group (cannot be null)
751      * @param rawContactsToAdd is an array of raw contact IDs for contacts that
752      *            should be added to the group
753      * @param callbackActivity is the activity to send the callback intent to
754      * @param callbackAction is the intent action for the callback intent
755      */
createNewGroupIntent(Context context, AccountWithDataSet account, String label, long[] rawContactsToAdd, Class<? extends Activity> callbackActivity, String callbackAction)756     public static Intent createNewGroupIntent(Context context, AccountWithDataSet account,
757             String label, long[] rawContactsToAdd, Class<? extends Activity> callbackActivity,
758             String callbackAction) {
759         Intent serviceIntent = new Intent(context, ContactSaveService.class);
760         serviceIntent.setAction(ContactSaveService.ACTION_CREATE_GROUP);
761         serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
762         serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
763         serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
764         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, label);
765         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
766 
767         // Callback intent will be invoked by the service once the new group is
768         // created.
769         Intent callbackIntent = new Intent(context, callbackActivity);
770         callbackIntent.setAction(callbackAction);
771         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
772 
773         return serviceIntent;
774     }
775 
createGroup(Intent intent)776     private void createGroup(Intent intent) {
777         String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
778         String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
779         String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
780         String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
781         final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
782 
783         // Create the new group
784         final Uri groupUri = mGroupsDao.create(label,
785                 new AccountWithDataSet(accountName, accountType, dataSet));
786         final ContentResolver resolver = getContentResolver();
787 
788         // If there's no URI, then the insertion failed. Abort early because group members can't be
789         // added if the group doesn't exist
790         if (groupUri == null) {
791             Log.e(TAG, "Couldn't create group with label " + label);
792             return;
793         }
794 
795         // Add new group members
796         addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri));
797 
798         ContentValues values = new ContentValues();
799         // TODO: Move this into the contact editor where it belongs. This needs to be integrated
800         // with the way other intent extras that are passed to the
801         // {@link ContactEditorActivity}.
802         values.clear();
803         values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
804         values.put(GroupMembership.GROUP_ROW_ID, ContentUris.parseId(groupUri));
805 
806         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
807         callbackIntent.setData(groupUri);
808         // TODO: This can be taken out when the above TODO is addressed
809         callbackIntent.putExtra(ContactsContract.Intents.Insert.DATA, Lists.newArrayList(values));
810         deliverCallback(callbackIntent);
811     }
812 
813     /**
814      * Creates an intent that can be sent to this service to rename a group.
815      */
createGroupRenameIntent(Context context, long groupId, String newLabel, Class<? extends Activity> callbackActivity, String callbackAction)816     public static Intent createGroupRenameIntent(Context context, long groupId, String newLabel,
817             Class<? extends Activity> callbackActivity, String callbackAction) {
818         Intent serviceIntent = new Intent(context, ContactSaveService.class);
819         serviceIntent.setAction(ContactSaveService.ACTION_RENAME_GROUP);
820         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
821         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
822 
823         // Callback intent will be invoked by the service once the group is renamed.
824         Intent callbackIntent = new Intent(context, callbackActivity);
825         callbackIntent.setAction(callbackAction);
826         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
827 
828         return serviceIntent;
829     }
830 
renameGroup(Intent intent)831     private void renameGroup(Intent intent) {
832         long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
833         String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
834 
835         if (groupId == -1) {
836             Log.e(TAG, "Invalid arguments for renameGroup request");
837             return;
838         }
839 
840         ContentValues values = new ContentValues();
841         values.put(Groups.TITLE, label);
842         final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
843         getContentResolver().update(groupUri, values, null, null);
844 
845         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
846         callbackIntent.setData(groupUri);
847         deliverCallback(callbackIntent);
848     }
849 
850     /**
851      * Creates an intent that can be sent to this service to delete a group.
852      */
createGroupDeletionIntent(Context context, long groupId)853     public static Intent createGroupDeletionIntent(Context context, long groupId) {
854         final Intent serviceIntent = new Intent(context, ContactSaveService.class);
855         serviceIntent.setAction(ContactSaveService.ACTION_DELETE_GROUP);
856         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
857 
858         return serviceIntent;
859     }
860 
deleteGroup(Intent intent)861     private void deleteGroup(Intent intent) {
862         long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
863         if (groupId == -1) {
864             Log.e(TAG, "Invalid arguments for deleteGroup request");
865             return;
866         }
867         final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
868 
869         final Intent callbackIntent = new Intent(BROADCAST_GROUP_DELETED);
870         final Bundle undoData = mGroupsDao.captureDeletionUndoData(groupUri);
871         callbackIntent.putExtra(EXTRA_UNDO_ACTION, ACTION_DELETE_GROUP);
872         callbackIntent.putExtra(EXTRA_UNDO_DATA, undoData);
873 
874         mGroupsDao.delete(groupUri);
875 
876         LocalBroadcastManager.getInstance(this).sendBroadcast(callbackIntent);
877     }
878 
createUndoIntent(Context context, Intent resultIntent)879     public static Intent createUndoIntent(Context context, Intent resultIntent) {
880         final Intent serviceIntent = new Intent(context, ContactSaveService.class);
881         serviceIntent.setAction(ContactSaveService.ACTION_UNDO);
882         serviceIntent.putExtras(resultIntent);
883         return serviceIntent;
884     }
885 
undo(Intent intent)886     private void undo(Intent intent) {
887         final String actionToUndo = intent.getStringExtra(EXTRA_UNDO_ACTION);
888         if (ACTION_DELETE_GROUP.equals(actionToUndo)) {
889             mGroupsDao.undoDeletion(intent.getBundleExtra(EXTRA_UNDO_DATA));
890         }
891     }
892 
893 
894     /**
895      * Creates an intent that can be sent to this service to rename a group as
896      * well as add and remove members from the group.
897      *
898      * @param context of the application
899      * @param groupId of the group that should be modified
900      * @param newLabel is the updated name of the group (can be null if the name
901      *            should not be updated)
902      * @param rawContactsToAdd is an array of raw contact IDs for contacts that
903      *            should be added to the group
904      * @param rawContactsToRemove is an array of raw contact IDs for contacts
905      *            that should be removed from the group
906      * @param callbackActivity is the activity to send the callback intent to
907      * @param callbackAction is the intent action for the callback intent
908      */
createGroupUpdateIntent(Context context, long groupId, String newLabel, long[] rawContactsToAdd, long[] rawContactsToRemove, Class<? extends Activity> callbackActivity, String callbackAction)909     public static Intent createGroupUpdateIntent(Context context, long groupId, String newLabel,
910             long[] rawContactsToAdd, long[] rawContactsToRemove,
911             Class<? extends Activity> callbackActivity, String callbackAction) {
912         Intent serviceIntent = new Intent(context, ContactSaveService.class);
913         serviceIntent.setAction(ContactSaveService.ACTION_UPDATE_GROUP);
914         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
915         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
916         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
917         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_REMOVE,
918                 rawContactsToRemove);
919 
920         // Callback intent will be invoked by the service once the group is updated
921         Intent callbackIntent = new Intent(context, callbackActivity);
922         callbackIntent.setAction(callbackAction);
923         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
924 
925         return serviceIntent;
926     }
927 
updateGroup(Intent intent)928     private void updateGroup(Intent intent) {
929         long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
930         String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
931         long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
932         long[] rawContactsToRemove = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_REMOVE);
933 
934         if (groupId == -1) {
935             Log.e(TAG, "Invalid arguments for updateGroup request");
936             return;
937         }
938 
939         final ContentResolver resolver = getContentResolver();
940         final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
941 
942         // Update group name if necessary
943         if (label != null) {
944             ContentValues values = new ContentValues();
945             values.put(Groups.TITLE, label);
946             resolver.update(groupUri, values, null, null);
947         }
948 
949         // Add and remove members if necessary
950         addMembersToGroup(resolver, rawContactsToAdd, groupId);
951         removeMembersFromGroup(resolver, rawContactsToRemove, groupId);
952 
953         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
954         callbackIntent.setData(groupUri);
955         deliverCallback(callbackIntent);
956     }
957 
addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd, long groupId)958     private void addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd,
959             long groupId) {
960         if (rawContactsToAdd == null) {
961             return;
962         }
963         for (long rawContactId : rawContactsToAdd) {
964             try {
965                 final ArrayList<ContentProviderOperation> rawContactOperations =
966                         new ArrayList<ContentProviderOperation>();
967 
968                 // Build an assert operation to ensure the contact is not already in the group
969                 final ContentProviderOperation.Builder assertBuilder = ContentProviderOperation
970                         .newAssertQuery(Data.CONTENT_URI);
971                 assertBuilder.withSelection(Data.RAW_CONTACT_ID + "=? AND " +
972                         Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
973                         new String[] { String.valueOf(rawContactId),
974                         GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
975                 assertBuilder.withExpectedCount(0);
976                 rawContactOperations.add(assertBuilder.build());
977 
978                 // Build an insert operation to add the contact to the group
979                 final ContentProviderOperation.Builder insertBuilder = ContentProviderOperation
980                         .newInsert(Data.CONTENT_URI);
981                 insertBuilder.withValue(Data.RAW_CONTACT_ID, rawContactId);
982                 insertBuilder.withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
983                 insertBuilder.withValue(GroupMembership.GROUP_ROW_ID, groupId);
984                 rawContactOperations.add(insertBuilder.build());
985 
986                 if (DEBUG) {
987                     for (ContentProviderOperation operation : rawContactOperations) {
988                         Log.v(TAG, operation.toString());
989                     }
990                 }
991 
992                 // Apply batch
993                 if (!rawContactOperations.isEmpty()) {
994                     resolver.applyBatch(ContactsContract.AUTHORITY, rawContactOperations);
995                 }
996             } catch (RemoteException e) {
997                 // Something went wrong, bail without success
998                 FeedbackHelper.sendFeedback(this, TAG,
999                         "Problem persisting user edits for raw contact ID " +
1000                                 String.valueOf(rawContactId), e);
1001             } catch (OperationApplicationException e) {
1002                 // The assert could have failed because the contact is already in the group,
1003                 // just continue to the next contact
1004                 FeedbackHelper.sendFeedback(this, TAG,
1005                         "Assert failed in adding raw contact ID " +
1006                                 String.valueOf(rawContactId) + ". Already exists in group " +
1007                                 String.valueOf(groupId), e);
1008             }
1009         }
1010     }
1011 
removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove, long groupId)1012     private static void removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove,
1013             long groupId) {
1014         if (rawContactsToRemove == null) {
1015             return;
1016         }
1017         for (long rawContactId : rawContactsToRemove) {
1018             // Apply the delete operation on the data row for the given raw contact's
1019             // membership in the given group. If no contact matches the provided selection, then
1020             // nothing will be done. Just continue to the next contact.
1021             resolver.delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=? AND " +
1022                     Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
1023                     new String[] { String.valueOf(rawContactId),
1024                     GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
1025         }
1026     }
1027 
1028     /**
1029      * Creates an intent that can be sent to this service to star or un-star a contact.
1030      */
createSetStarredIntent(Context context, Uri contactUri, boolean value)1031     public static Intent createSetStarredIntent(Context context, Uri contactUri, boolean value) {
1032         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1033         serviceIntent.setAction(ContactSaveService.ACTION_SET_STARRED);
1034         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
1035         serviceIntent.putExtra(ContactSaveService.EXTRA_STARRED_FLAG, value);
1036 
1037         return serviceIntent;
1038     }
1039 
setStarred(Intent intent)1040     private void setStarred(Intent intent) {
1041         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1042         boolean value = intent.getBooleanExtra(EXTRA_STARRED_FLAG, false);
1043         if (contactUri == null) {
1044             Log.e(TAG, "Invalid arguments for setStarred request");
1045             return;
1046         }
1047 
1048         final ContentValues values = new ContentValues(1);
1049         values.put(Contacts.STARRED, value);
1050         getContentResolver().update(contactUri, values, null, null);
1051 
1052         // Undemote the contact if necessary
1053         final Cursor c = getContentResolver().query(contactUri, new String[] {Contacts._ID},
1054                 null, null, null);
1055         if (c == null) {
1056             return;
1057         }
1058         try {
1059             if (c.moveToFirst()) {
1060                 final long id = c.getLong(0);
1061 
1062                 // Don't bother undemoting if this contact is the user's profile.
1063                 if (id < Profile.MIN_ID) {
1064                     PinnedPositionsCompat.undemote(getContentResolver(), id);
1065                 }
1066             }
1067         } finally {
1068             c.close();
1069         }
1070     }
1071 
1072     /**
1073      * Creates an intent that can be sent to this service to set the redirect to voicemail.
1074      */
createSetSendToVoicemail(Context context, Uri contactUri, boolean value)1075     public static Intent createSetSendToVoicemail(Context context, Uri contactUri,
1076             boolean value) {
1077         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1078         serviceIntent.setAction(ContactSaveService.ACTION_SET_SEND_TO_VOICEMAIL);
1079         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
1080         serviceIntent.putExtra(ContactSaveService.EXTRA_SEND_TO_VOICEMAIL_FLAG, value);
1081 
1082         return serviceIntent;
1083     }
1084 
setSendToVoicemail(Intent intent)1085     private void setSendToVoicemail(Intent intent) {
1086         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1087         boolean value = intent.getBooleanExtra(EXTRA_SEND_TO_VOICEMAIL_FLAG, false);
1088         if (contactUri == null) {
1089             Log.e(TAG, "Invalid arguments for setRedirectToVoicemail");
1090             return;
1091         }
1092 
1093         final ContentValues values = new ContentValues(1);
1094         values.put(Contacts.SEND_TO_VOICEMAIL, value);
1095         getContentResolver().update(contactUri, values, null, null);
1096     }
1097 
1098     /**
1099      * Creates an intent that can be sent to this service to save the contact's ringtone.
1100      */
createSetRingtone(Context context, Uri contactUri, String value)1101     public static Intent createSetRingtone(Context context, Uri contactUri,
1102             String value) {
1103         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1104         serviceIntent.setAction(ContactSaveService.ACTION_SET_RINGTONE);
1105         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
1106         serviceIntent.putExtra(ContactSaveService.EXTRA_CUSTOM_RINGTONE, value);
1107 
1108         return serviceIntent;
1109     }
1110 
setRingtone(Intent intent)1111     private void setRingtone(Intent intent) {
1112         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1113         String value = intent.getStringExtra(EXTRA_CUSTOM_RINGTONE);
1114         if (contactUri == null) {
1115             Log.e(TAG, "Invalid arguments for setRingtone");
1116             return;
1117         }
1118         ContentValues values = new ContentValues(1);
1119         values.put(Contacts.CUSTOM_RINGTONE, value);
1120         getContentResolver().update(contactUri, values, null, null);
1121     }
1122 
1123     /**
1124      * Creates an intent that sets the selected data item as super primary (default)
1125      */
createSetSuperPrimaryIntent(Context context, long dataId)1126     public static Intent createSetSuperPrimaryIntent(Context context, long dataId) {
1127         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1128         serviceIntent.setAction(ContactSaveService.ACTION_SET_SUPER_PRIMARY);
1129         serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
1130         return serviceIntent;
1131     }
1132 
setSuperPrimary(Intent intent)1133     private void setSuperPrimary(Intent intent) {
1134         long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
1135         if (dataId == -1) {
1136             Log.e(TAG, "Invalid arguments for setSuperPrimary request");
1137             return;
1138         }
1139 
1140         ContactUpdateUtils.setSuperPrimary(this, dataId);
1141     }
1142 
1143     /**
1144      * Creates an intent that clears the primary flag of all data items that belong to the same
1145      * raw_contact as the given data item. Will only clear, if the data item was primary before
1146      * this call
1147      */
createClearPrimaryIntent(Context context, long dataId)1148     public static Intent createClearPrimaryIntent(Context context, long dataId) {
1149         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1150         serviceIntent.setAction(ContactSaveService.ACTION_CLEAR_PRIMARY);
1151         serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
1152         return serviceIntent;
1153     }
1154 
clearPrimary(Intent intent)1155     private void clearPrimary(Intent intent) {
1156         long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
1157         if (dataId == -1) {
1158             Log.e(TAG, "Invalid arguments for clearPrimary request");
1159             return;
1160         }
1161 
1162         // Update the primary values in the data record.
1163         ContentValues values = new ContentValues(1);
1164         values.put(Data.IS_SUPER_PRIMARY, 0);
1165         values.put(Data.IS_PRIMARY, 0);
1166 
1167         getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId),
1168                 values, null, null);
1169     }
1170 
1171     /**
1172      * Creates an intent that can be sent to this service to delete a contact.
1173      */
createDeleteContactIntent(Context context, Uri contactUri)1174     public static Intent createDeleteContactIntent(Context context, Uri contactUri) {
1175         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1176         serviceIntent.setAction(ContactSaveService.ACTION_DELETE_CONTACT);
1177         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
1178         return serviceIntent;
1179     }
1180 
1181     /**
1182      * Creates an intent that can be sent to this service to delete multiple contacts.
1183      */
createDeleteMultipleContactsIntent(Context context, long[] contactIds, final String[] names)1184     public static Intent createDeleteMultipleContactsIntent(Context context,
1185             long[] contactIds, final String[] names) {
1186         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1187         serviceIntent.setAction(ContactSaveService.ACTION_DELETE_MULTIPLE_CONTACTS);
1188         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
1189         serviceIntent.putExtra(ContactSaveService.EXTRA_DISPLAY_NAME_ARRAY, names);
1190         return serviceIntent;
1191     }
1192 
deleteContact(Intent intent)1193     private void deleteContact(Intent intent) {
1194         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1195         if (contactUri == null) {
1196             Log.e(TAG, "Invalid arguments for deleteContact request");
1197             return;
1198         }
1199 
1200         getContentResolver().delete(contactUri, null, null);
1201     }
1202 
deleteMultipleContacts(Intent intent)1203     private void deleteMultipleContacts(Intent intent) {
1204         final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
1205         if (contactIds == null) {
1206             Log.e(TAG, "Invalid arguments for deleteMultipleContacts request");
1207             return;
1208         }
1209         for (long contactId : contactIds) {
1210             final Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
1211             getContentResolver().delete(contactUri, null, null);
1212         }
1213         final String[] names = intent.getStringArrayExtra(
1214                 ContactSaveService.EXTRA_DISPLAY_NAME_ARRAY);
1215         final String deleteToastMessage;
1216         if (contactIds.length != names.length || names.length == 0) {
1217             deleteToastMessage = getResources().getQuantityString(
1218                     R.plurals.contacts_deleted_toast, contactIds.length);
1219         } else if (names.length == 1) {
1220             deleteToastMessage = getResources().getString(
1221                     R.string.contacts_deleted_one_named_toast, (Object[]) names);
1222         } else if (names.length == 2) {
1223             deleteToastMessage = getResources().getString(
1224                     R.string.contacts_deleted_two_named_toast, (Object[]) names);
1225         } else {
1226             deleteToastMessage = getResources().getString(
1227                     R.string.contacts_deleted_many_named_toast, (Object[]) names);
1228         }
1229 
1230         mMainHandler.post(new Runnable() {
1231             @Override
1232             public void run() {
1233                 Toast.makeText(ContactSaveService.this, deleteToastMessage, Toast.LENGTH_LONG)
1234                         .show();
1235             }
1236         });
1237     }
1238 
1239     /**
1240      * Creates an intent that can be sent to this service to split a contact into it's constituent
1241      * pieces. This will set the raw contact ids to {@link AggregationExceptions#TYPE_AUTOMATIC} so
1242      * they may be re-merged by the auto-aggregator.
1243      */
createSplitContactIntent(Context context, long[][] rawContactIds, ResultReceiver receiver)1244     public static Intent createSplitContactIntent(Context context, long[][] rawContactIds,
1245             ResultReceiver receiver) {
1246         final Intent serviceIntent = new Intent(context, ContactSaveService.class);
1247         serviceIntent.setAction(ContactSaveService.ACTION_SPLIT_CONTACT);
1248         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACT_IDS, rawContactIds);
1249         serviceIntent.putExtra(ContactSaveService.EXTRA_RESULT_RECEIVER, receiver);
1250         return serviceIntent;
1251     }
1252 
1253     /**
1254      * Creates an intent that can be sent to this service to split a contact into it's constituent
1255      * pieces. This will explicitly set the raw contact ids to
1256      * {@link AggregationExceptions#TYPE_KEEP_SEPARATE}.
1257      */
createHardSplitContactIntent(Context context, long[][] rawContactIds)1258     public static Intent createHardSplitContactIntent(Context context, long[][] rawContactIds) {
1259         final Intent serviceIntent = new Intent(context, ContactSaveService.class);
1260         serviceIntent.setAction(ContactSaveService.ACTION_SPLIT_CONTACT);
1261         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACT_IDS, rawContactIds);
1262         serviceIntent.putExtra(ContactSaveService.EXTRA_HARD_SPLIT, true);
1263         return serviceIntent;
1264     }
1265 
splitContact(Intent intent)1266     private void splitContact(Intent intent) {
1267         final long rawContactIds[][] = (long[][]) intent
1268                 .getSerializableExtra(EXTRA_RAW_CONTACT_IDS);
1269         final ResultReceiver receiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER);
1270         final boolean hardSplit = intent.getBooleanExtra(EXTRA_HARD_SPLIT, false);
1271         if (rawContactIds == null) {
1272             Log.e(TAG, "Invalid argument for splitContact request");
1273             if (receiver != null) {
1274                 receiver.send(BAD_ARGUMENTS, new Bundle());
1275             }
1276             return;
1277         }
1278         final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE;
1279         final ContentResolver resolver = getContentResolver();
1280         final ArrayList<ContentProviderOperation> operations = new ArrayList<>(batchSize);
1281         for (int i = 0; i < rawContactIds.length; i++) {
1282             for (int j = 0; j < rawContactIds.length; j++) {
1283                 if (i != j) {
1284                     if (!buildSplitTwoContacts(operations, rawContactIds[i], rawContactIds[j],
1285                             hardSplit)) {
1286                         if (receiver != null) {
1287                             receiver.send(CP2_ERROR, new Bundle());
1288                             return;
1289                         }
1290                     }
1291                 }
1292             }
1293         }
1294         if (operations.size() > 0 && !applyOperations(resolver, operations)) {
1295             if (receiver != null) {
1296                 receiver.send(CP2_ERROR, new Bundle());
1297             }
1298             return;
1299         }
1300         LocalBroadcastManager.getInstance(this)
1301                 .sendBroadcast(new Intent(BROADCAST_UNLINK_COMPLETE));
1302         if (receiver != null) {
1303             receiver.send(CONTACTS_SPLIT, new Bundle());
1304         } else {
1305             showToast(R.string.contactUnlinkedToast);
1306         }
1307     }
1308 
1309     /**
1310      * Insert aggregation exception ContentProviderOperations between {@param rawContactIds1}
1311      * and {@param rawContactIds2} to {@param operations}.
1312      * @return false if an error occurred, true otherwise.
1313      */
buildSplitTwoContacts(ArrayList<ContentProviderOperation> operations, long[] rawContactIds1, long[] rawContactIds2, boolean hardSplit)1314     private boolean buildSplitTwoContacts(ArrayList<ContentProviderOperation> operations,
1315             long[] rawContactIds1, long[] rawContactIds2, boolean hardSplit) {
1316         if (rawContactIds1 == null || rawContactIds2 == null) {
1317             Log.e(TAG, "Invalid arguments for splitContact request");
1318             return false;
1319         }
1320         // For each pair of raw contacts, insert an aggregation exception
1321         final ContentResolver resolver = getContentResolver();
1322         // The maximum number of operations per batch (aka yield point) is 500. See b/22480225
1323         final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE;
1324         for (int i = 0; i < rawContactIds1.length; i++) {
1325             for (int j = 0; j < rawContactIds2.length; j++) {
1326                 buildSplitContactDiff(operations, rawContactIds1[i], rawContactIds2[j], hardSplit);
1327                 // Before we get to 500 we need to flush the operations list
1328                 if (operations.size() > 0 && operations.size() % batchSize == 0) {
1329                     if (!applyOperations(resolver, operations)) {
1330                         return false;
1331                     }
1332                     operations.clear();
1333                 }
1334             }
1335         }
1336         return true;
1337     }
1338 
1339     /**
1340      * Creates an intent that can be sent to this service to join two contacts.
1341      * The resulting contact uses the name from {@param contactId1} if possible.
1342      */
createJoinContactsIntent(Context context, long contactId1, long contactId2, Class<? extends Activity> callbackActivity, String callbackAction)1343     public static Intent createJoinContactsIntent(Context context, long contactId1,
1344             long contactId2, Class<? extends Activity> callbackActivity, String callbackAction) {
1345         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1346         serviceIntent.setAction(ContactSaveService.ACTION_JOIN_CONTACTS);
1347         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID1, contactId1);
1348         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID2, contactId2);
1349 
1350         // Callback intent will be invoked by the service once the contacts are joined.
1351         Intent callbackIntent = new Intent(context, callbackActivity);
1352         callbackIntent.setAction(callbackAction);
1353         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
1354 
1355         return serviceIntent;
1356     }
1357 
1358     /**
1359      * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts.
1360      * No special attention is paid to where the resulting contact's name is taken from.
1361      */
createJoinSeveralContactsIntent(Context context, long[] contactIds, ResultReceiver receiver)1362     public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds,
1363             ResultReceiver receiver) {
1364         final Intent serviceIntent = new Intent(context, ContactSaveService.class);
1365         serviceIntent.setAction(ContactSaveService.ACTION_JOIN_SEVERAL_CONTACTS);
1366         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
1367         serviceIntent.putExtra(ContactSaveService.EXTRA_RESULT_RECEIVER, receiver);
1368         return serviceIntent;
1369     }
1370 
1371     /**
1372      * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts.
1373      * No special attention is paid to where the resulting contact's name is taken from.
1374      */
createJoinSeveralContactsIntent(Context context, long[] contactIds)1375     public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds) {
1376         return createJoinSeveralContactsIntent(context, contactIds, /* receiver = */ null);
1377     }
1378 
1379     private interface JoinContactQuery {
1380         String[] PROJECTION = {
1381                 RawContacts._ID,
1382                 RawContacts.CONTACT_ID,
1383                 RawContacts.DISPLAY_NAME_SOURCE,
1384         };
1385 
1386         int _ID = 0;
1387         int CONTACT_ID = 1;
1388         int DISPLAY_NAME_SOURCE = 2;
1389     }
1390 
1391     private interface ContactEntityQuery {
1392         String[] PROJECTION = {
1393                 Contacts.Entity.DATA_ID,
1394                 Contacts.Entity.CONTACT_ID,
1395                 Contacts.Entity.IS_SUPER_PRIMARY,
1396         };
1397         String SELECTION = Data.MIMETYPE + " = '" + StructuredName.CONTENT_ITEM_TYPE + "'" +
1398                 " AND " + StructuredName.DISPLAY_NAME + "=" + Contacts.DISPLAY_NAME +
1399                 " AND " + StructuredName.DISPLAY_NAME + " IS NOT NULL " +
1400                 " AND " + StructuredName.DISPLAY_NAME + " != '' ";
1401 
1402         int DATA_ID = 0;
1403         int CONTACT_ID = 1;
1404         int IS_SUPER_PRIMARY = 2;
1405     }
1406 
joinSeveralContacts(Intent intent)1407     private void joinSeveralContacts(Intent intent) {
1408         final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
1409 
1410         final ResultReceiver receiver = intent.getParcelableExtra(EXTRA_RESULT_RECEIVER);
1411 
1412         // Load raw contact IDs for all contacts involved.
1413         final long rawContactIds[] = getRawContactIdsForAggregation(contactIds);
1414         final long[][] separatedRawContactIds = getSeparatedRawContactIds(contactIds);
1415         if (rawContactIds == null) {
1416             Log.e(TAG, "Invalid arguments for joinSeveralContacts request");
1417             if (receiver != null) {
1418                 receiver.send(BAD_ARGUMENTS, new Bundle());
1419             }
1420             return;
1421         }
1422 
1423         // For each pair of raw contacts, insert an aggregation exception
1424         final ContentResolver resolver = getContentResolver();
1425         // The maximum number of operations per batch (aka yield point) is 500. See b/22480225
1426         final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE;
1427         final ArrayList<ContentProviderOperation> operations = new ArrayList<>(batchSize);
1428         for (int i = 0; i < rawContactIds.length; i++) {
1429             for (int j = 0; j < rawContactIds.length; j++) {
1430                 if (i != j) {
1431                     buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
1432                 }
1433                 // Before we get to 500 we need to flush the operations list
1434                 if (operations.size() > 0 && operations.size() % batchSize == 0) {
1435                     if (!applyOperations(resolver, operations)) {
1436                         if (receiver != null) {
1437                             receiver.send(CP2_ERROR, new Bundle());
1438                         }
1439                         return;
1440                     }
1441                     operations.clear();
1442                 }
1443             }
1444         }
1445         if (operations.size() > 0 && !applyOperations(resolver, operations)) {
1446             if (receiver != null) {
1447                 receiver.send(CP2_ERROR, new Bundle());
1448             }
1449             return;
1450         }
1451 
1452 
1453         final String name = queryNameOfLinkedContacts(contactIds);
1454         if (name != null) {
1455             if (receiver != null) {
1456                 final Bundle result = new Bundle();
1457                 result.putSerializable(EXTRA_RAW_CONTACT_IDS, separatedRawContactIds);
1458                 result.putString(EXTRA_DISPLAY_NAME, name);
1459                 receiver.send(CONTACTS_LINKED, result);
1460             } else {
1461                 if (TextUtils.isEmpty(name)) {
1462                     showToast(R.string.contactsJoinedMessage);
1463                 } else {
1464                     showToast(R.string.contactsJoinedNamedMessage, name);
1465                 }
1466             }
1467             LocalBroadcastManager.getInstance(this)
1468                     .sendBroadcast(new Intent(BROADCAST_LINK_COMPLETE));
1469         } else {
1470             if (receiver != null) {
1471                 receiver.send(CP2_ERROR, new Bundle());
1472             }
1473             showToast(R.string.contactJoinErrorToast);
1474         }
1475     }
1476 
1477     /** Get the display name of the top-level contact after the contacts have been linked. */
queryNameOfLinkedContacts(long[] contactIds)1478     private String queryNameOfLinkedContacts(long[] contactIds) {
1479         final StringBuilder whereBuilder = new StringBuilder(Contacts._ID).append(" IN (");
1480         final String[] whereArgs = new String[contactIds.length];
1481         for (int i = 0; i < contactIds.length; i++) {
1482             whereArgs[i] = String.valueOf(contactIds[i]);
1483             whereBuilder.append("?,");
1484         }
1485         whereBuilder.deleteCharAt(whereBuilder.length() - 1).append(')');
1486         final Cursor cursor = getContentResolver().query(Contacts.CONTENT_URI,
1487                 new String[]{Contacts._ID, Contacts.DISPLAY_NAME,
1488                         Contacts.DISPLAY_NAME_ALTERNATIVE},
1489                 whereBuilder.toString(), whereArgs, null);
1490 
1491         String name = null;
1492         String nameAlt = null;
1493         long contactId = 0;
1494         try {
1495             if (cursor.moveToFirst()) {
1496                 contactId = cursor.getLong(0);
1497                 name = cursor.getString(1);
1498                 nameAlt = cursor.getString(2);
1499             }
1500             while(cursor.moveToNext()) {
1501                 if (cursor.getLong(0) != contactId) {
1502                     return null;
1503                 }
1504             }
1505 
1506             final String formattedName = ContactDisplayUtils.getPreferredDisplayName(name, nameAlt,
1507                     new ContactsPreferences(getApplicationContext()));
1508             return formattedName == null ? "" : formattedName;
1509         } finally {
1510             if (cursor != null) {
1511                 cursor.close();
1512             }
1513         }
1514     }
1515 
1516     /** Returns true if the batch was successfully applied and false otherwise. */
applyOperations(ContentResolver resolver, ArrayList<ContentProviderOperation> operations)1517     private boolean applyOperations(ContentResolver resolver,
1518             ArrayList<ContentProviderOperation> operations) {
1519         try {
1520             final ContentProviderResult[] result =
1521                     resolver.applyBatch(ContactsContract.AUTHORITY, operations);
1522             for (int i = 0; i < result.length; ++i) {
1523                 // if no rows were modified in the operation then we count it as fail.
1524                 if (result[i].count < 0) {
1525                     throw new OperationApplicationException();
1526                 }
1527             }
1528             return true;
1529         } catch (RemoteException | OperationApplicationException e) {
1530             FeedbackHelper.sendFeedback(this, TAG,
1531                     "Failed to apply aggregation exception batch", e);
1532             showToast(R.string.contactSavedErrorToast);
1533             return false;
1534         }
1535     }
1536 
joinContacts(Intent intent)1537     private void joinContacts(Intent intent) {
1538         long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1);
1539         long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1);
1540 
1541         // Load raw contact IDs for all raw contacts involved - currently edited and selected
1542         // in the join UIs.
1543         long rawContactIds[] = getRawContactIdsForAggregation(contactId1, contactId2);
1544         if (rawContactIds == null) {
1545             Log.e(TAG, "Invalid arguments for joinContacts request");
1546             return;
1547         }
1548 
1549         ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
1550 
1551         // For each pair of raw contacts, insert an aggregation exception
1552         for (int i = 0; i < rawContactIds.length; i++) {
1553             for (int j = 0; j < rawContactIds.length; j++) {
1554                 if (i != j) {
1555                     buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
1556                 }
1557             }
1558         }
1559 
1560         final ContentResolver resolver = getContentResolver();
1561 
1562         // Use the name for contactId1 as the name for the newly aggregated contact.
1563         final Uri contactId1Uri = ContentUris.withAppendedId(
1564                 Contacts.CONTENT_URI, contactId1);
1565         final Uri entityUri = Uri.withAppendedPath(
1566                 contactId1Uri, Contacts.Entity.CONTENT_DIRECTORY);
1567         Cursor c = resolver.query(entityUri,
1568                 ContactEntityQuery.PROJECTION, ContactEntityQuery.SELECTION, null, null);
1569         if (c == null) {
1570             Log.e(TAG, "Unable to open Contacts DB cursor");
1571             showToast(R.string.contactSavedErrorToast);
1572             return;
1573         }
1574         long dataIdToAddSuperPrimary = -1;
1575         try {
1576             if (c.moveToFirst()) {
1577                 dataIdToAddSuperPrimary = c.getLong(ContactEntityQuery.DATA_ID);
1578             }
1579         } finally {
1580             c.close();
1581         }
1582 
1583         // Mark the name from contactId1 IS_SUPER_PRIMARY to make sure that the contact
1584         // display name does not change as a result of the join.
1585         if (dataIdToAddSuperPrimary != -1) {
1586             Builder builder = ContentProviderOperation.newUpdate(
1587                     ContentUris.withAppendedId(Data.CONTENT_URI, dataIdToAddSuperPrimary));
1588             builder.withValue(Data.IS_SUPER_PRIMARY, 1);
1589             builder.withValue(Data.IS_PRIMARY, 1);
1590             operations.add(builder.build());
1591         }
1592 
1593         // Apply all aggregation exceptions as one batch
1594         final boolean success = applyOperations(resolver, operations);
1595 
1596         final String name = queryNameOfLinkedContacts(new long[] {contactId1, contactId2});
1597         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
1598         if (success && name != null) {
1599             if (TextUtils.isEmpty(name)) {
1600                 showToast(R.string.contactsJoinedMessage);
1601             } else {
1602                 showToast(R.string.contactsJoinedNamedMessage, name);
1603             }
1604             Uri uri = RawContacts.getContactLookupUri(resolver,
1605                     ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0]));
1606             callbackIntent.setData(uri);
1607             LocalBroadcastManager.getInstance(this)
1608                     .sendBroadcast(new Intent(BROADCAST_LINK_COMPLETE));
1609         }
1610         deliverCallback(callbackIntent);
1611     }
1612 
1613     /**
1614      * Gets the raw contact ids for each contact id in {@param contactIds}. Each index of the outer
1615      * array of the return value holds an array of raw contact ids for one contactId.
1616      * @param contactIds
1617      * @return
1618      */
getSeparatedRawContactIds(long[] contactIds)1619     private long[][] getSeparatedRawContactIds(long[] contactIds) {
1620         final long[][] rawContactIds = new long[contactIds.length][];
1621         for (int i = 0; i < contactIds.length; i++) {
1622             rawContactIds[i] = getRawContactIds(contactIds[i]);
1623         }
1624         return rawContactIds;
1625     }
1626 
1627     /**
1628      * Gets the raw contact ids associated with {@param contactId}.
1629      * @param contactId
1630      * @return Array of raw contact ids.
1631      */
getRawContactIds(long contactId)1632     private long[] getRawContactIds(long contactId) {
1633         final ContentResolver resolver = getContentResolver();
1634         long rawContactIds[];
1635 
1636         final StringBuilder queryBuilder = new StringBuilder();
1637             queryBuilder.append(RawContacts.CONTACT_ID)
1638                     .append("=")
1639                     .append(String.valueOf(contactId));
1640 
1641         final Cursor c = resolver.query(RawContacts.CONTENT_URI,
1642                 JoinContactQuery.PROJECTION,
1643                 queryBuilder.toString(),
1644                 null, null);
1645         if (c == null) {
1646             Log.e(TAG, "Unable to open Contacts DB cursor");
1647             return null;
1648         }
1649         try {
1650             rawContactIds = new long[c.getCount()];
1651             for (int i = 0; i < rawContactIds.length; i++) {
1652                 c.moveToPosition(i);
1653                 final long rawContactId = c.getLong(JoinContactQuery._ID);
1654                 rawContactIds[i] = rawContactId;
1655             }
1656         } finally {
1657             c.close();
1658         }
1659         return rawContactIds;
1660     }
1661 
getRawContactIdsForAggregation(long[] contactIds)1662     private long[] getRawContactIdsForAggregation(long[] contactIds) {
1663         if (contactIds == null) {
1664             return null;
1665         }
1666 
1667         final ContentResolver resolver = getContentResolver();
1668 
1669         final StringBuilder queryBuilder = new StringBuilder();
1670         final String stringContactIds[] = new String[contactIds.length];
1671         for (int i = 0; i < contactIds.length; i++) {
1672             queryBuilder.append(RawContacts.CONTACT_ID + "=?");
1673             stringContactIds[i] = String.valueOf(contactIds[i]);
1674             if (contactIds[i] == -1) {
1675                 return null;
1676             }
1677             if (i == contactIds.length -1) {
1678                 break;
1679             }
1680             queryBuilder.append(" OR ");
1681         }
1682 
1683         final Cursor c = resolver.query(RawContacts.CONTENT_URI,
1684                 JoinContactQuery.PROJECTION,
1685                 queryBuilder.toString(),
1686                 stringContactIds, null);
1687         if (c == null) {
1688             Log.e(TAG, "Unable to open Contacts DB cursor");
1689             showToast(R.string.contactSavedErrorToast);
1690             return null;
1691         }
1692         long rawContactIds[];
1693         try {
1694             if (c.getCount() < 2) {
1695                 Log.e(TAG, "Not enough raw contacts to aggregate together.");
1696                 return null;
1697             }
1698             rawContactIds = new long[c.getCount()];
1699             for (int i = 0; i < rawContactIds.length; i++) {
1700                 c.moveToPosition(i);
1701                 long rawContactId = c.getLong(JoinContactQuery._ID);
1702                 rawContactIds[i] = rawContactId;
1703             }
1704         } finally {
1705             c.close();
1706         }
1707         return rawContactIds;
1708     }
1709 
getRawContactIdsForAggregation(long contactId1, long contactId2)1710     private long[] getRawContactIdsForAggregation(long contactId1, long contactId2) {
1711         return getRawContactIdsForAggregation(new long[] {contactId1, contactId2});
1712     }
1713 
1714     /**
1715      * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation.
1716      */
buildJoinContactDiff(ArrayList<ContentProviderOperation> operations, long rawContactId1, long rawContactId2)1717     private void buildJoinContactDiff(ArrayList<ContentProviderOperation> operations,
1718             long rawContactId1, long rawContactId2) {
1719         Builder builder =
1720                 ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
1721         builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
1722         builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
1723         builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
1724         operations.add(builder.build());
1725     }
1726 
1727     /**
1728      * Construct a {@link AggregationExceptions#TYPE_AUTOMATIC} or a
1729      * {@link AggregationExceptions#TYPE_KEEP_SEPARATE} ContentProviderOperation if a hard split is
1730      * requested.
1731      */
buildSplitContactDiff(ArrayList<ContentProviderOperation> operations, long rawContactId1, long rawContactId2, boolean hardSplit)1732     private void buildSplitContactDiff(ArrayList<ContentProviderOperation> operations,
1733             long rawContactId1, long rawContactId2, boolean hardSplit) {
1734         final Builder builder =
1735                 ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
1736         builder.withValue(AggregationExceptions.TYPE,
1737                 hardSplit
1738                         ? AggregationExceptions.TYPE_KEEP_SEPARATE
1739                         : AggregationExceptions.TYPE_AUTOMATIC);
1740         builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
1741         builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
1742         operations.add(builder.build());
1743     }
1744 
1745     /**
1746      * Returns an intent that can start this service and cause it to sleep for the specified time.
1747      *
1748      * This exists purely for debugging and manual testing. Since this service uses a single thread
1749      * it is useful to have a way to test behavior when work is queued up and most of the other
1750      * operations complete too quickly to simulate that under normal conditions.
1751      */
createSleepIntent(Context context, long millis)1752     public static Intent createSleepIntent(Context context, long millis) {
1753         return new Intent(context, ContactSaveService.class).setAction(ACTION_SLEEP)
1754                 .putExtra(EXTRA_SLEEP_DURATION, millis);
1755     }
1756 
sleepForDebugging(Intent intent)1757     private void sleepForDebugging(Intent intent) {
1758         long duration = intent.getLongExtra(EXTRA_SLEEP_DURATION, 1000);
1759         if (Log.isLoggable(TAG, Log.DEBUG)) {
1760             Log.d(TAG, "sleeping for " + duration + "ms");
1761         }
1762         try {
1763             Thread.sleep(duration);
1764         } catch (InterruptedException e) {
1765             e.printStackTrace();
1766         }
1767         if (Log.isLoggable(TAG, Log.DEBUG)) {
1768             Log.d(TAG, "finished sleeping");
1769         }
1770     }
1771 
1772     /**
1773      * Shows a toast on the UI thread by formatting messageId using args.
1774      * @param messageId id of message string
1775      * @param args args to format string
1776      */
showToast(final int messageId, final Object... args)1777     private void showToast(final int messageId, final Object... args) {
1778         final String message = getResources().getString(messageId, args);
1779         mMainHandler.post(new Runnable() {
1780             @Override
1781             public void run() {
1782                 Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show();
1783             }
1784         });
1785     }
1786 
1787 
1788     /**
1789      * Shows a toast on the UI thread.
1790      */
showToast(final int message)1791     private void showToast(final int message) {
1792         mMainHandler.post(new Runnable() {
1793 
1794             @Override
1795             public void run() {
1796                 Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show();
1797             }
1798         });
1799     }
1800 
deliverCallback(final Intent callbackIntent)1801     private void deliverCallback(final Intent callbackIntent) {
1802         mMainHandler.post(new Runnable() {
1803 
1804             @Override
1805             public void run() {
1806                 deliverCallbackOnUiThread(callbackIntent);
1807             }
1808         });
1809     }
1810 
deliverCallbackOnUiThread(final Intent callbackIntent)1811     void deliverCallbackOnUiThread(final Intent callbackIntent) {
1812         // TODO: this assumes that if there are multiple instances of the same
1813         // activity registered, the last one registered is the one waiting for
1814         // the callback. Validity of this assumption needs to be verified.
1815         for (Listener listener : sListeners) {
1816             if (callbackIntent.getComponent().equals(
1817                     ((Activity) listener).getIntent().getComponent())) {
1818                 listener.onServiceCompleted(callbackIntent);
1819                 return;
1820             }
1821         }
1822     }
1823 
1824     public interface GroupsDao {
create(String title, AccountWithDataSet account)1825         Uri create(String title, AccountWithDataSet account);
delete(Uri groupUri)1826         int delete(Uri groupUri);
captureDeletionUndoData(Uri groupUri)1827         Bundle captureDeletionUndoData(Uri groupUri);
undoDeletion(Bundle undoData)1828         Uri undoDeletion(Bundle undoData);
1829     }
1830 
1831     public static class GroupsDaoImpl implements GroupsDao {
1832         public static final String KEY_GROUP_DATA = "groupData";
1833         public static final String KEY_GROUP_MEMBERS = "groupMemberIds";
1834 
1835         private static final String TAG = "GroupsDao";
1836         private final Context context;
1837         private final ContentResolver contentResolver;
1838 
GroupsDaoImpl(Context context)1839         public GroupsDaoImpl(Context context) {
1840             this(context, context.getContentResolver());
1841         }
1842 
GroupsDaoImpl(Context context, ContentResolver contentResolver)1843         public GroupsDaoImpl(Context context, ContentResolver contentResolver) {
1844             this.context = context;
1845             this.contentResolver = contentResolver;
1846         }
1847 
captureDeletionUndoData(Uri groupUri)1848         public Bundle captureDeletionUndoData(Uri groupUri) {
1849             final long groupId = ContentUris.parseId(groupUri);
1850             final Bundle result = new Bundle();
1851 
1852             final Cursor cursor = contentResolver.query(groupUri,
1853                     new String[]{
1854                             Groups.TITLE, Groups.NOTES, Groups.GROUP_VISIBLE,
1855                             Groups.ACCOUNT_TYPE, Groups.ACCOUNT_NAME, Groups.DATA_SET,
1856                             Groups.SHOULD_SYNC
1857                     },
1858                     Groups.DELETED + "=?", new String[] { "0" }, null);
1859             try {
1860                 if (cursor.moveToFirst()) {
1861                     final ContentValues groupValues = new ContentValues();
1862                     DatabaseUtils.cursorRowToContentValues(cursor, groupValues);
1863                     result.putParcelable(KEY_GROUP_DATA, groupValues);
1864                 } else {
1865                     // Group doesn't exist.
1866                     return result;
1867                 }
1868             } finally {
1869                 cursor.close();
1870             }
1871 
1872             final Cursor membersCursor = contentResolver.query(
1873                     Data.CONTENT_URI, new String[] { Data.RAW_CONTACT_ID },
1874                     Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
1875                     new String[] { GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId) }, null);
1876             final long[] memberIds = new long[membersCursor.getCount()];
1877             int i = 0;
1878             while (membersCursor.moveToNext()) {
1879                 memberIds[i++] = membersCursor.getLong(0);
1880             }
1881             result.putLongArray(KEY_GROUP_MEMBERS, memberIds);
1882             return result;
1883         }
1884 
undoDeletion(Bundle deletedGroupData)1885         public Uri undoDeletion(Bundle deletedGroupData) {
1886             final ContentValues groupData = deletedGroupData.getParcelable(KEY_GROUP_DATA);
1887             if (groupData == null) {
1888                 return null;
1889             }
1890             final Uri groupUri = contentResolver.insert(Groups.CONTENT_URI, groupData);
1891             final long groupId = ContentUris.parseId(groupUri);
1892 
1893             final long[] memberIds = deletedGroupData.getLongArray(KEY_GROUP_MEMBERS);
1894             if (memberIds == null) {
1895                 return groupUri;
1896             }
1897             final ContentValues[] memberInsertions = new ContentValues[memberIds.length];
1898             for (int i = 0; i < memberIds.length; i++) {
1899                 memberInsertions[i] = new ContentValues();
1900                 memberInsertions[i].put(Data.RAW_CONTACT_ID, memberIds[i]);
1901                 memberInsertions[i].put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
1902                 memberInsertions[i].put(GroupMembership.GROUP_ROW_ID, groupId);
1903             }
1904             final int inserted = contentResolver.bulkInsert(Data.CONTENT_URI, memberInsertions);
1905             if (inserted != memberIds.length) {
1906                 Log.e(TAG, "Could not recover some members for group deletion undo");
1907             }
1908 
1909             return groupUri;
1910         }
1911 
create(String title, AccountWithDataSet account)1912         public Uri create(String title, AccountWithDataSet account) {
1913             final ContentValues values = new ContentValues();
1914             values.put(Groups.TITLE, title);
1915             values.put(Groups.ACCOUNT_NAME, account.name);
1916             values.put(Groups.ACCOUNT_TYPE, account.type);
1917             values.put(Groups.DATA_SET, account.dataSet);
1918             return contentResolver.insert(Groups.CONTENT_URI, values);
1919         }
1920 
delete(Uri groupUri)1921         public int delete(Uri groupUri) {
1922             return contentResolver.delete(groupUri, null, null);
1923         }
1924     }
1925 
1926     /**
1927      * Keeps track of which operations have been requested but have not yet finished for this
1928      * service.
1929      */
1930     public static class State {
1931         private final CopyOnWriteArrayList<Intent> mPending;
1932 
State()1933         public State() {
1934             mPending = new CopyOnWriteArrayList<>();
1935         }
1936 
State(Collection<Intent> pendingActions)1937         public State(Collection<Intent> pendingActions) {
1938             mPending = new CopyOnWriteArrayList<>(pendingActions);
1939         }
1940 
isIdle()1941         public boolean isIdle() {
1942             return mPending.isEmpty();
1943         }
1944 
getCurrentIntent()1945         public Intent getCurrentIntent() {
1946             return mPending.isEmpty() ? null : mPending.get(0);
1947         }
1948 
1949         /**
1950          * Returns the first intent requested that has the specified action or null if no intent
1951          * with that action has been requested.
1952          */
getNextIntentWithAction(String action)1953         public Intent getNextIntentWithAction(String action) {
1954             for (Intent intent : mPending) {
1955                 if (action.equals(intent.getAction())) {
1956                     return intent;
1957                 }
1958             }
1959             return null;
1960         }
1961 
isActionPending(String action)1962         public boolean isActionPending(String action) {
1963             return getNextIntentWithAction(action) != null;
1964         }
1965 
onFinish(Intent intent)1966         private void onFinish(Intent intent) {
1967             if (mPending.isEmpty()) {
1968                 return;
1969             }
1970             final String action = mPending.get(0).getAction();
1971             if (action.equals(intent.getAction())) {
1972                 mPending.remove(0);
1973             }
1974         }
1975 
onStart(Intent intent)1976         private void onStart(Intent intent) {
1977             if (intent.getAction() == null) {
1978                 return;
1979             }
1980             mPending.add(intent);
1981         }
1982     }
1983 }
1984