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 import android.app.Activity;
21 import android.app.IntentService;
22 import android.content.ContentProviderOperation;
23 import android.content.ContentProviderOperation.Builder;
24 import android.content.ContentProviderResult;
25 import android.content.ContentResolver;
26 import android.content.ContentUris;
27 import android.content.ContentValues;
28 import android.content.Context;
29 import android.content.Intent;
30 import android.content.OperationApplicationException;
31 import android.database.Cursor;
32 import android.net.Uri;
33 import android.os.Bundle;
34 import android.os.Handler;
35 import android.os.Looper;
36 import android.os.Parcelable;
37 import android.os.RemoteException;
38 import android.provider.ContactsContract;
39 import android.provider.ContactsContract.AggregationExceptions;
40 import android.provider.ContactsContract.CommonDataKinds.GroupMembership;
41 import android.provider.ContactsContract.CommonDataKinds.StructuredName;
42 import android.provider.ContactsContract.Contacts;
43 import android.provider.ContactsContract.Data;
44 import android.provider.ContactsContract.Groups;
45 import android.provider.ContactsContract.Profile;
46 import android.provider.ContactsContract.RawContacts;
47 import android.provider.ContactsContract.RawContactsEntity;
48 import android.util.Log;
49 import android.widget.Toast;
50 
51 import com.android.contacts.activities.ContactEditorBaseActivity;
52 import com.android.contacts.common.compat.CompatUtils;
53 import com.android.contacts.common.database.ContactUpdateUtils;
54 import com.android.contacts.common.model.AccountTypeManager;
55 import com.android.contacts.common.model.CPOWrapper;
56 import com.android.contacts.common.model.RawContactDelta;
57 import com.android.contacts.common.model.RawContactDeltaList;
58 import com.android.contacts.common.model.RawContactModifier;
59 import com.android.contacts.common.model.account.AccountWithDataSet;
60 import com.android.contacts.common.util.PermissionsUtil;
61 import com.android.contacts.compat.PinnedPositionsCompat;
62 import com.android.contacts.activities.ContactEditorBaseActivity.ContactEditor.SaveMode;
63 import com.android.contacts.util.ContactPhotoUtils;
64 
65 import com.google.common.collect.Lists;
66 import com.google.common.collect.Sets;
67 
68 import java.util.ArrayList;
69 import java.util.HashSet;
70 import java.util.List;
71 import java.util.concurrent.CopyOnWriteArrayList;
72 
73 /**
74  * A service responsible for saving changes to the content provider.
75  */
76 public class ContactSaveService extends IntentService {
77     private static final String TAG = "ContactSaveService";
78 
79     /** Set to true in order to view logs on content provider operations */
80     private static final boolean DEBUG = false;
81 
82     public static final String ACTION_NEW_RAW_CONTACT = "newRawContact";
83 
84     public static final String EXTRA_ACCOUNT_NAME = "accountName";
85     public static final String EXTRA_ACCOUNT_TYPE = "accountType";
86     public static final String EXTRA_DATA_SET = "dataSet";
87     public static final String EXTRA_CONTENT_VALUES = "contentValues";
88     public static final String EXTRA_CALLBACK_INTENT = "callbackIntent";
89 
90     public static final String ACTION_SAVE_CONTACT = "saveContact";
91     public static final String EXTRA_CONTACT_STATE = "state";
92     public static final String EXTRA_SAVE_MODE = "saveMode";
93     public static final String EXTRA_SAVE_IS_PROFILE = "saveIsProfile";
94     public static final String EXTRA_SAVE_SUCCEEDED = "saveSucceeded";
95     public static final String EXTRA_UPDATED_PHOTOS = "updatedPhotos";
96 
97     public static final String ACTION_CREATE_GROUP = "createGroup";
98     public static final String ACTION_RENAME_GROUP = "renameGroup";
99     public static final String ACTION_DELETE_GROUP = "deleteGroup";
100     public static final String ACTION_UPDATE_GROUP = "updateGroup";
101     public static final String EXTRA_GROUP_ID = "groupId";
102     public static final String EXTRA_GROUP_LABEL = "groupLabel";
103     public static final String EXTRA_RAW_CONTACTS_TO_ADD = "rawContactsToAdd";
104     public static final String EXTRA_RAW_CONTACTS_TO_REMOVE = "rawContactsToRemove";
105 
106     public static final String ACTION_SET_STARRED = "setStarred";
107     public static final String ACTION_DELETE_CONTACT = "delete";
108     public static final String ACTION_DELETE_MULTIPLE_CONTACTS = "deleteMultipleContacts";
109     public static final String EXTRA_CONTACT_URI = "contactUri";
110     public static final String EXTRA_CONTACT_IDS = "contactIds";
111     public static final String EXTRA_STARRED_FLAG = "starred";
112 
113     public static final String ACTION_SET_SUPER_PRIMARY = "setSuperPrimary";
114     public static final String ACTION_CLEAR_PRIMARY = "clearPrimary";
115     public static final String EXTRA_DATA_ID = "dataId";
116 
117     public static final String ACTION_JOIN_CONTACTS = "joinContacts";
118     public static final String ACTION_JOIN_SEVERAL_CONTACTS = "joinSeveralContacts";
119     public static final String EXTRA_CONTACT_ID1 = "contactId1";
120     public static final String EXTRA_CONTACT_ID2 = "contactId2";
121 
122     public static final String ACTION_SET_SEND_TO_VOICEMAIL = "sendToVoicemail";
123     public static final String EXTRA_SEND_TO_VOICEMAIL_FLAG = "sendToVoicemailFlag";
124 
125     public static final String ACTION_SET_RINGTONE = "setRingtone";
126     public static final String EXTRA_CUSTOM_RINGTONE = "customRingtone";
127 
128     private static final HashSet<String> ALLOWED_DATA_COLUMNS = Sets.newHashSet(
129         Data.MIMETYPE,
130         Data.IS_PRIMARY,
131         Data.DATA1,
132         Data.DATA2,
133         Data.DATA3,
134         Data.DATA4,
135         Data.DATA5,
136         Data.DATA6,
137         Data.DATA7,
138         Data.DATA8,
139         Data.DATA9,
140         Data.DATA10,
141         Data.DATA11,
142         Data.DATA12,
143         Data.DATA13,
144         Data.DATA14,
145         Data.DATA15
146     );
147 
148     private static final int PERSIST_TRIES = 3;
149 
150     private static final int MAX_CONTACTS_PROVIDER_BATCH_SIZE = 499;
151 
152     public interface Listener {
onServiceCompleted(Intent callbackIntent)153         public void onServiceCompleted(Intent callbackIntent);
154     }
155 
156     private static final CopyOnWriteArrayList<Listener> sListeners =
157             new CopyOnWriteArrayList<Listener>();
158 
159     private Handler mMainHandler;
160 
ContactSaveService()161     public ContactSaveService() {
162         super(TAG);
163         setIntentRedelivery(true);
164         mMainHandler = new Handler(Looper.getMainLooper());
165     }
166 
registerListener(Listener listener)167     public static void registerListener(Listener listener) {
168         if (!(listener instanceof Activity)) {
169             throw new ClassCastException("Only activities can be registered to"
170                     + " receive callback from " + ContactSaveService.class.getName());
171         }
172         sListeners.add(0, listener);
173     }
174 
unregisterListener(Listener listener)175     public static void unregisterListener(Listener listener) {
176         sListeners.remove(listener);
177     }
178 
179     /**
180      * Returns true if the ContactSaveService was started successfully and false if an exception
181      * was thrown and a Toast error message was displayed.
182      */
startService(Context context, Intent intent, int saveMode)183     public static boolean startService(Context context, Intent intent, int saveMode) {
184         try {
185             context.startService(intent);
186         } catch (Exception exception) {
187             final int resId;
188             switch (saveMode) {
189                 case ContactEditorBaseActivity.ContactEditor.SaveMode.SPLIT:
190                     resId = R.string.contactUnlinkErrorToast;
191                     break;
192                 case ContactEditorBaseActivity.ContactEditor.SaveMode.RELOAD:
193                     resId = R.string.contactJoinErrorToast;
194                     break;
195                 case ContactEditorBaseActivity.ContactEditor.SaveMode.CLOSE:
196                     resId = R.string.contactSavedErrorToast;
197                     break;
198                 default:
199                     resId = R.string.contactGenericErrorToast;
200             }
201             Toast.makeText(context, resId, Toast.LENGTH_SHORT).show();
202             return false;
203         }
204         return true;
205     }
206 
207     /**
208      * Utility method that starts service and handles exception.
209      */
startService(Context context, Intent intent)210     public static void startService(Context context, Intent intent) {
211         try {
212             context.startService(intent);
213         } catch (Exception exception) {
214             Toast.makeText(context, R.string.contactGenericErrorToast, Toast.LENGTH_SHORT).show();
215         }
216     }
217 
218     @Override
getSystemService(String name)219     public Object getSystemService(String name) {
220         Object service = super.getSystemService(name);
221         if (service != null) {
222             return service;
223         }
224 
225         return getApplicationContext().getSystemService(name);
226     }
227 
228     @Override
onHandleIntent(Intent intent)229     protected void onHandleIntent(Intent intent) {
230         if (intent == null) {
231             Log.d(TAG, "onHandleIntent: could not handle null intent");
232             return;
233         }
234         if (!PermissionsUtil.hasPermission(this, WRITE_CONTACTS)) {
235             Log.w(TAG, "No WRITE_CONTACTS permission, unable to write to CP2");
236             // TODO: add more specific error string such as "Turn on Contacts
237             // permission to update your contacts"
238             showToast(R.string.contactSavedErrorToast);
239             return;
240         }
241 
242         // Call an appropriate method. If we're sure it affects how incoming phone calls are
243         // handled, then notify the fact to in-call screen.
244         String action = intent.getAction();
245         if (ACTION_NEW_RAW_CONTACT.equals(action)) {
246             createRawContact(intent);
247         } else if (ACTION_SAVE_CONTACT.equals(action)) {
248             saveContact(intent);
249         } else if (ACTION_CREATE_GROUP.equals(action)) {
250             createGroup(intent);
251         } else if (ACTION_RENAME_GROUP.equals(action)) {
252             renameGroup(intent);
253         } else if (ACTION_DELETE_GROUP.equals(action)) {
254             deleteGroup(intent);
255         } else if (ACTION_UPDATE_GROUP.equals(action)) {
256             updateGroup(intent);
257         } else if (ACTION_SET_STARRED.equals(action)) {
258             setStarred(intent);
259         } else if (ACTION_SET_SUPER_PRIMARY.equals(action)) {
260             setSuperPrimary(intent);
261         } else if (ACTION_CLEAR_PRIMARY.equals(action)) {
262             clearPrimary(intent);
263         } else if (ACTION_DELETE_MULTIPLE_CONTACTS.equals(action)) {
264             deleteMultipleContacts(intent);
265         } else if (ACTION_DELETE_CONTACT.equals(action)) {
266             deleteContact(intent);
267         } else if (ACTION_JOIN_CONTACTS.equals(action)) {
268             joinContacts(intent);
269         } else if (ACTION_JOIN_SEVERAL_CONTACTS.equals(action)) {
270             joinSeveralContacts(intent);
271         } else if (ACTION_SET_SEND_TO_VOICEMAIL.equals(action)) {
272             setSendToVoicemail(intent);
273         } else if (ACTION_SET_RINGTONE.equals(action)) {
274             setRingtone(intent);
275         }
276     }
277 
278     /**
279      * Creates an intent that can be sent to this service to create a new raw contact
280      * using data presented as a set of ContentValues.
281      */
createNewRawContactIntent(Context context, ArrayList<ContentValues> values, AccountWithDataSet account, Class<? extends Activity> callbackActivity, String callbackAction)282     public static Intent createNewRawContactIntent(Context context,
283             ArrayList<ContentValues> values, AccountWithDataSet account,
284             Class<? extends Activity> callbackActivity, String callbackAction) {
285         Intent serviceIntent = new Intent(
286                 context, ContactSaveService.class);
287         serviceIntent.setAction(ContactSaveService.ACTION_NEW_RAW_CONTACT);
288         if (account != null) {
289             serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
290             serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
291             serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
292         }
293         serviceIntent.putParcelableArrayListExtra(
294                 ContactSaveService.EXTRA_CONTENT_VALUES, values);
295 
296         // Callback intent will be invoked by the service once the new contact is
297         // created.  The service will put the URI of the new contact as "data" on
298         // the callback intent.
299         Intent callbackIntent = new Intent(context, callbackActivity);
300         callbackIntent.setAction(callbackAction);
301         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
302         return serviceIntent;
303     }
304 
createRawContact(Intent intent)305     private void createRawContact(Intent intent) {
306         String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
307         String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
308         String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
309         List<ContentValues> valueList = intent.getParcelableArrayListExtra(EXTRA_CONTENT_VALUES);
310         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
311 
312         ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
313         operations.add(ContentProviderOperation.newInsert(RawContacts.CONTENT_URI)
314                 .withValue(RawContacts.ACCOUNT_NAME, accountName)
315                 .withValue(RawContacts.ACCOUNT_TYPE, accountType)
316                 .withValue(RawContacts.DATA_SET, dataSet)
317                 .build());
318 
319         int size = valueList.size();
320         for (int i = 0; i < size; i++) {
321             ContentValues values = valueList.get(i);
322             values.keySet().retainAll(ALLOWED_DATA_COLUMNS);
323             operations.add(ContentProviderOperation.newInsert(Data.CONTENT_URI)
324                     .withValueBackReference(Data.RAW_CONTACT_ID, 0)
325                     .withValues(values)
326                     .build());
327         }
328 
329         ContentResolver resolver = getContentResolver();
330         ContentProviderResult[] results;
331         try {
332             results = resolver.applyBatch(ContactsContract.AUTHORITY, operations);
333         } catch (Exception e) {
334             throw new RuntimeException("Failed to store new contact", e);
335         }
336 
337         Uri rawContactUri = results[0].uri;
338         callbackIntent.setData(RawContacts.getContactLookupUri(resolver, rawContactUri));
339 
340         deliverCallback(callbackIntent);
341     }
342 
343     /**
344      * Creates an intent that can be sent to this service to create a new raw contact
345      * using data presented as a set of ContentValues.
346      * This variant is more convenient to use when there is only one photo that can
347      * possibly be updated, as in the Contact Details screen.
348      * @param rawContactId identifies a writable raw-contact whose photo is to be updated.
349      * @param updatedPhotoPath denotes a temporary file containing the contact's new photo.
350      */
createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class<? extends Activity> callbackActivity, String callbackAction, long rawContactId, Uri updatedPhotoPath)351     public static Intent createSaveContactIntent(Context context, RawContactDeltaList state,
352             String saveModeExtraKey, int saveMode, boolean isProfile,
353             Class<? extends Activity> callbackActivity, String callbackAction, long rawContactId,
354             Uri updatedPhotoPath) {
355         Bundle bundle = new Bundle();
356         bundle.putParcelable(String.valueOf(rawContactId), updatedPhotoPath);
357         return createSaveContactIntent(context, state, saveModeExtraKey, saveMode, isProfile,
358                 callbackActivity, callbackAction, bundle,
359                 /* joinContactIdExtraKey */ null, /* joinContactId */ null);
360     }
361 
362     /**
363      * Creates an intent that can be sent to this service to create a new raw contact
364      * using data presented as a set of ContentValues.
365      * This variant is used when multiple contacts' photos may be updated, as in the
366      * Contact Editor.
367      *
368      * @param updatedPhotos maps each raw-contact's ID to the file-path of the new photo.
369      * @param joinContactIdExtraKey the key used to pass the joinContactId in the callback intent.
370      * @param joinContactId the raw contact ID to join to the contact after doing the save.
371      */
createSaveContactIntent(Context context, RawContactDeltaList state, String saveModeExtraKey, int saveMode, boolean isProfile, Class<? extends Activity> callbackActivity, String callbackAction, Bundle updatedPhotos, String joinContactIdExtraKey, Long joinContactId)372     public static Intent createSaveContactIntent(Context context, RawContactDeltaList state,
373             String saveModeExtraKey, int saveMode, boolean isProfile,
374             Class<? extends Activity> callbackActivity, String callbackAction,
375             Bundle updatedPhotos, String joinContactIdExtraKey, Long joinContactId) {
376         Intent serviceIntent = new Intent(
377                 context, ContactSaveService.class);
378         serviceIntent.setAction(ContactSaveService.ACTION_SAVE_CONTACT);
379         serviceIntent.putExtra(EXTRA_CONTACT_STATE, (Parcelable) state);
380         serviceIntent.putExtra(EXTRA_SAVE_IS_PROFILE, isProfile);
381         serviceIntent.putExtra(EXTRA_SAVE_MODE, saveMode);
382 
383         if (updatedPhotos != null) {
384             serviceIntent.putExtra(EXTRA_UPDATED_PHOTOS, (Parcelable) updatedPhotos);
385         }
386 
387         if (callbackActivity != null) {
388             // Callback intent will be invoked by the service once the contact is
389             // saved.  The service will put the URI of the new contact as "data" on
390             // the callback intent.
391             Intent callbackIntent = new Intent(context, callbackActivity);
392             callbackIntent.putExtra(saveModeExtraKey, saveMode);
393             if (joinContactIdExtraKey != null && joinContactId != null) {
394                 callbackIntent.putExtra(joinContactIdExtraKey, joinContactId);
395             }
396             callbackIntent.setAction(callbackAction);
397             serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
398         }
399         return serviceIntent;
400     }
401 
saveContact(Intent intent)402     private void saveContact(Intent intent) {
403         RawContactDeltaList state = intent.getParcelableExtra(EXTRA_CONTACT_STATE);
404         boolean isProfile = intent.getBooleanExtra(EXTRA_SAVE_IS_PROFILE, false);
405         Bundle updatedPhotos = intent.getParcelableExtra(EXTRA_UPDATED_PHOTOS);
406 
407         if (state == null) {
408             Log.e(TAG, "Invalid arguments for saveContact request");
409             return;
410         }
411 
412         int saveMode = intent.getIntExtra(EXTRA_SAVE_MODE, -1);
413         // Trim any empty fields, and RawContacts, before persisting
414         final AccountTypeManager accountTypes = AccountTypeManager.getInstance(this);
415         RawContactModifier.trimEmpty(state, accountTypes);
416 
417         Uri lookupUri = null;
418 
419         final ContentResolver resolver = getContentResolver();
420 
421         boolean succeeded = false;
422 
423         // Keep track of the id of a newly raw-contact (if any... there can be at most one).
424         long insertedRawContactId = -1;
425 
426         // Attempt to persist changes
427         int tries = 0;
428         while (tries++ < PERSIST_TRIES) {
429             try {
430                 // Build operations and try applying
431                 final ArrayList<CPOWrapper> diffWrapper = state.buildDiffWrapper();
432 
433                 final ArrayList<ContentProviderOperation> diff = Lists.newArrayList();
434 
435                 for (CPOWrapper cpoWrapper : diffWrapper) {
436                     diff.add(cpoWrapper.getOperation());
437                 }
438 
439                 if (DEBUG) {
440                     Log.v(TAG, "Content Provider Operations:");
441                     for (ContentProviderOperation operation : diff) {
442                         Log.v(TAG, operation.toString());
443                     }
444                 }
445 
446                 int numberProcessed = 0;
447                 boolean batchFailed = false;
448                 final ContentProviderResult[] results = new ContentProviderResult[diff.size()];
449                 while (numberProcessed < diff.size()) {
450                     final int subsetCount = applyDiffSubset(diff, numberProcessed, results, resolver);
451                     if (subsetCount == -1) {
452                         Log.w(TAG, "Resolver.applyBatch failed in saveContacts");
453                         batchFailed = true;
454                         break;
455                     } else {
456                         numberProcessed += subsetCount;
457                     }
458                 }
459 
460                 if (batchFailed) {
461                     // Retry save
462                     continue;
463                 }
464 
465                 final long rawContactId = getRawContactId(state, diffWrapper, results);
466                 if (rawContactId == -1) {
467                     throw new IllegalStateException("Could not determine RawContact ID after save");
468                 }
469                 // We don't have to check to see if the value is still -1.  If we reach here,
470                 // the previous loop iteration didn't succeed, so any ID that we obtained is bogus.
471                 insertedRawContactId = getInsertedRawContactId(diffWrapper, results);
472                 if (isProfile) {
473                     // Since the profile supports local raw contacts, which may have been completely
474                     // removed if all information was removed, we need to do a special query to
475                     // get the lookup URI for the profile contact (if it still exists).
476                     Cursor c = resolver.query(Profile.CONTENT_URI,
477                             new String[] {Contacts._ID, Contacts.LOOKUP_KEY},
478                             null, null, null);
479                     if (c == null) {
480                         continue;
481                     }
482                     try {
483                         if (c.moveToFirst()) {
484                             final long contactId = c.getLong(0);
485                             final String lookupKey = c.getString(1);
486                             lookupUri = Contacts.getLookupUri(contactId, lookupKey);
487                         }
488                     } finally {
489                         c.close();
490                     }
491                 } else {
492                     final Uri rawContactUri = ContentUris.withAppendedId(RawContacts.CONTENT_URI,
493                                     rawContactId);
494                     lookupUri = RawContacts.getContactLookupUri(resolver, rawContactUri);
495                 }
496                 if (lookupUri != null) {
497                     Log.v(TAG, "Saved contact. New URI: " + lookupUri);
498                 }
499 
500                 // We can change this back to false later, if we fail to save the contact photo.
501                 succeeded = true;
502                 break;
503 
504             } catch (RemoteException e) {
505                 // Something went wrong, bail without success
506                 Log.e(TAG, "Problem persisting user edits", e);
507                 break;
508 
509             } catch (IllegalArgumentException e) {
510                 // This is thrown by applyBatch on malformed requests
511                 Log.e(TAG, "Problem persisting user edits", e);
512                 showToast(R.string.contactSavedErrorToast);
513                 break;
514 
515             } catch (OperationApplicationException e) {
516                 // Version consistency failed, re-parent change and try again
517                 Log.w(TAG, "Version consistency failed, re-parenting: " + e.toString());
518                 final StringBuilder sb = new StringBuilder(RawContacts._ID + " IN(");
519                 boolean first = true;
520                 final int count = state.size();
521                 for (int i = 0; i < count; i++) {
522                     Long rawContactId = state.getRawContactId(i);
523                     if (rawContactId != null && rawContactId != -1) {
524                         if (!first) {
525                             sb.append(',');
526                         }
527                         sb.append(rawContactId);
528                         first = false;
529                     }
530                 }
531                 sb.append(")");
532 
533                 if (first) {
534                     throw new IllegalStateException(
535                             "Version consistency failed for a new contact", e);
536                 }
537 
538                 final RawContactDeltaList newState = RawContactDeltaList.fromQuery(
539                         isProfile
540                                 ? RawContactsEntity.PROFILE_CONTENT_URI
541                                 : RawContactsEntity.CONTENT_URI,
542                         resolver, sb.toString(), null, null);
543                 state = RawContactDeltaList.mergeAfter(newState, state);
544 
545                 // Update the new state to use profile URIs if appropriate.
546                 if (isProfile) {
547                     for (RawContactDelta delta : state) {
548                         delta.setProfileQueryUri();
549                     }
550                 }
551             }
552         }
553 
554         // Now save any updated photos.  We do this at the end to ensure that
555         // the ContactProvider already knows about newly-created contacts.
556         if (updatedPhotos != null) {
557             for (String key : updatedPhotos.keySet()) {
558                 Uri photoUri = updatedPhotos.getParcelable(key);
559                 long rawContactId = Long.parseLong(key);
560 
561                 // If the raw-contact ID is negative, we are saving a new raw-contact;
562                 // replace the bogus ID with the new one that we actually saved the contact at.
563                 if (rawContactId < 0) {
564                     rawContactId = insertedRawContactId;
565                 }
566 
567                 // If the save failed, insertedRawContactId will be -1
568                 if (rawContactId < 0 || !saveUpdatedPhoto(rawContactId, photoUri, saveMode)) {
569                     succeeded = false;
570                 }
571             }
572         }
573 
574         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
575         if (callbackIntent != null) {
576             if (succeeded) {
577                 // Mark the intent to indicate that the save was successful (even if the lookup URI
578                 // is now null).  For local contacts or the local profile, it's possible that the
579                 // save triggered removal of the contact, so no lookup URI would exist..
580                 callbackIntent.putExtra(EXTRA_SAVE_SUCCEEDED, true);
581             }
582             callbackIntent.setData(lookupUri);
583             deliverCallback(callbackIntent);
584         }
585     }
586 
587     /**
588      * Splits "diff" into subsets based on "MAX_CONTACTS_PROVIDER_BATCH_SIZE", applies each of the
589      * subsets, adds the returned array to "results".
590      *
591      * @return the size of the array, if not null; -1 when the array is null.
592      */
applyDiffSubset(ArrayList<ContentProviderOperation> diff, int offset, ContentProviderResult[] results, ContentResolver resolver)593     private int applyDiffSubset(ArrayList<ContentProviderOperation> diff, int offset,
594             ContentProviderResult[] results, ContentResolver resolver)
595             throws RemoteException, OperationApplicationException {
596         final int subsetCount = Math.min(diff.size() - offset, MAX_CONTACTS_PROVIDER_BATCH_SIZE);
597         final ArrayList<ContentProviderOperation> subset = new ArrayList<>();
598         subset.addAll(diff.subList(offset, offset + subsetCount));
599         final ContentProviderResult[] subsetResult = resolver.applyBatch(ContactsContract
600                 .AUTHORITY, subset);
601         if (subsetResult == null || (offset + subsetResult.length) > results.length) {
602             return -1;
603         }
604         for (ContentProviderResult c : subsetResult) {
605             results[offset++] = c;
606         }
607         return subsetResult.length;
608     }
609 
610     /**
611      * Save updated photo for the specified raw-contact.
612      * @return true for success, false for failure
613      */
saveUpdatedPhoto(long rawContactId, Uri photoUri, int saveMode)614     private boolean saveUpdatedPhoto(long rawContactId, Uri photoUri, int saveMode) {
615         final Uri outputUri = Uri.withAppendedPath(
616                 ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactId),
617                 RawContacts.DisplayPhoto.CONTENT_DIRECTORY);
618 
619         return ContactPhotoUtils.savePhotoFromUriToUri(this, photoUri, outputUri, (saveMode == 0));
620     }
621 
622     /**
623      * Find the ID of an existing or newly-inserted raw-contact.  If none exists, return -1.
624      */
getRawContactId(RawContactDeltaList state, final ArrayList<CPOWrapper> diffWrapper, final ContentProviderResult[] results)625     private long getRawContactId(RawContactDeltaList state,
626             final ArrayList<CPOWrapper> diffWrapper,
627             final ContentProviderResult[] results) {
628         long existingRawContactId = state.findRawContactId();
629         if (existingRawContactId != -1) {
630             return existingRawContactId;
631         }
632 
633         return getInsertedRawContactId(diffWrapper, results);
634     }
635 
636     /**
637      * Find the ID of a newly-inserted raw-contact.  If none exists, return -1.
638      */
getInsertedRawContactId( final ArrayList<CPOWrapper> diffWrapper, final ContentProviderResult[] results)639     private long getInsertedRawContactId(
640             final ArrayList<CPOWrapper> diffWrapper, final ContentProviderResult[] results) {
641         if (results == null) {
642             return -1;
643         }
644         final int diffSize = diffWrapper.size();
645         final int numResults = results.length;
646         for (int i = 0; i < diffSize && i < numResults; i++) {
647             final CPOWrapper cpoWrapper = diffWrapper.get(i);
648             final boolean isInsert = CompatUtils.isInsertCompat(cpoWrapper);
649             if (isInsert && cpoWrapper.getOperation().getUri().getEncodedPath().contains(
650                     RawContacts.CONTENT_URI.getEncodedPath())) {
651                 return ContentUris.parseId(results[i].uri);
652             }
653         }
654         return -1;
655     }
656 
657     /**
658      * Creates an intent that can be sent to this service to create a new group as
659      * well as add new members at the same time.
660      *
661      * @param context of the application
662      * @param account in which the group should be created
663      * @param label is the name of the group (cannot be null)
664      * @param rawContactsToAdd is an array of raw contact IDs for contacts that
665      *            should be added to the group
666      * @param callbackActivity is the activity to send the callback intent to
667      * @param callbackAction is the intent action for the callback intent
668      */
createNewGroupIntent(Context context, AccountWithDataSet account, String label, long[] rawContactsToAdd, Class<? extends Activity> callbackActivity, String callbackAction)669     public static Intent createNewGroupIntent(Context context, AccountWithDataSet account,
670             String label, long[] rawContactsToAdd, Class<? extends Activity> callbackActivity,
671             String callbackAction) {
672         Intent serviceIntent = new Intent(context, ContactSaveService.class);
673         serviceIntent.setAction(ContactSaveService.ACTION_CREATE_GROUP);
674         serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_TYPE, account.type);
675         serviceIntent.putExtra(ContactSaveService.EXTRA_ACCOUNT_NAME, account.name);
676         serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_SET, account.dataSet);
677         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, label);
678         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
679 
680         // Callback intent will be invoked by the service once the new group is
681         // created.
682         Intent callbackIntent = new Intent(context, callbackActivity);
683         callbackIntent.setAction(callbackAction);
684         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
685 
686         return serviceIntent;
687     }
688 
createGroup(Intent intent)689     private void createGroup(Intent intent) {
690         String accountType = intent.getStringExtra(EXTRA_ACCOUNT_TYPE);
691         String accountName = intent.getStringExtra(EXTRA_ACCOUNT_NAME);
692         String dataSet = intent.getStringExtra(EXTRA_DATA_SET);
693         String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
694         final long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
695 
696         ContentValues values = new ContentValues();
697         values.put(Groups.ACCOUNT_TYPE, accountType);
698         values.put(Groups.ACCOUNT_NAME, accountName);
699         values.put(Groups.DATA_SET, dataSet);
700         values.put(Groups.TITLE, label);
701 
702         final ContentResolver resolver = getContentResolver();
703 
704         // Create the new group
705         final Uri groupUri = resolver.insert(Groups.CONTENT_URI, values);
706 
707         // If there's no URI, then the insertion failed. Abort early because group members can't be
708         // added if the group doesn't exist
709         if (groupUri == null) {
710             Log.e(TAG, "Couldn't create group with label " + label);
711             return;
712         }
713 
714         // Add new group members
715         addMembersToGroup(resolver, rawContactsToAdd, ContentUris.parseId(groupUri));
716 
717         // TODO: Move this into the contact editor where it belongs. This needs to be integrated
718         // with the way other intent extras that are passed to the {@link ContactEditorActivity}.
719         values.clear();
720         values.put(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
721         values.put(GroupMembership.GROUP_ROW_ID, ContentUris.parseId(groupUri));
722 
723         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
724         callbackIntent.setData(groupUri);
725         // TODO: This can be taken out when the above TODO is addressed
726         callbackIntent.putExtra(ContactsContract.Intents.Insert.DATA, Lists.newArrayList(values));
727         deliverCallback(callbackIntent);
728     }
729 
730     /**
731      * Creates an intent that can be sent to this service to rename a group.
732      */
createGroupRenameIntent(Context context, long groupId, String newLabel, Class<? extends Activity> callbackActivity, String callbackAction)733     public static Intent createGroupRenameIntent(Context context, long groupId, String newLabel,
734             Class<? extends Activity> callbackActivity, String callbackAction) {
735         Intent serviceIntent = new Intent(context, ContactSaveService.class);
736         serviceIntent.setAction(ContactSaveService.ACTION_RENAME_GROUP);
737         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
738         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
739 
740         // Callback intent will be invoked by the service once the group is renamed.
741         Intent callbackIntent = new Intent(context, callbackActivity);
742         callbackIntent.setAction(callbackAction);
743         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
744 
745         return serviceIntent;
746     }
747 
renameGroup(Intent intent)748     private void renameGroup(Intent intent) {
749         long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
750         String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
751 
752         if (groupId == -1) {
753             Log.e(TAG, "Invalid arguments for renameGroup request");
754             return;
755         }
756 
757         ContentValues values = new ContentValues();
758         values.put(Groups.TITLE, label);
759         final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
760         getContentResolver().update(groupUri, values, null, null);
761 
762         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
763         callbackIntent.setData(groupUri);
764         deliverCallback(callbackIntent);
765     }
766 
767     /**
768      * Creates an intent that can be sent to this service to delete a group.
769      */
createGroupDeletionIntent(Context context, long groupId)770     public static Intent createGroupDeletionIntent(Context context, long groupId) {
771         Intent serviceIntent = new Intent(context, ContactSaveService.class);
772         serviceIntent.setAction(ContactSaveService.ACTION_DELETE_GROUP);
773         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
774         return serviceIntent;
775     }
776 
deleteGroup(Intent intent)777     private void deleteGroup(Intent intent) {
778         long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
779         if (groupId == -1) {
780             Log.e(TAG, "Invalid arguments for deleteGroup request");
781             return;
782         }
783 
784         getContentResolver().delete(
785                 ContentUris.withAppendedId(Groups.CONTENT_URI, groupId), null, null);
786     }
787 
788     /**
789      * Creates an intent that can be sent to this service to rename a group as
790      * well as add and remove members from the group.
791      *
792      * @param context of the application
793      * @param groupId of the group that should be modified
794      * @param newLabel is the updated name of the group (can be null if the name
795      *            should not be updated)
796      * @param rawContactsToAdd is an array of raw contact IDs for contacts that
797      *            should be added to the group
798      * @param rawContactsToRemove is an array of raw contact IDs for contacts
799      *            that should be removed from the group
800      * @param callbackActivity is the activity to send the callback intent to
801      * @param callbackAction is the intent action for the callback intent
802      */
createGroupUpdateIntent(Context context, long groupId, String newLabel, long[] rawContactsToAdd, long[] rawContactsToRemove, Class<? extends Activity> callbackActivity, String callbackAction)803     public static Intent createGroupUpdateIntent(Context context, long groupId, String newLabel,
804             long[] rawContactsToAdd, long[] rawContactsToRemove,
805             Class<? extends Activity> callbackActivity, String callbackAction) {
806         Intent serviceIntent = new Intent(context, ContactSaveService.class);
807         serviceIntent.setAction(ContactSaveService.ACTION_UPDATE_GROUP);
808         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_ID, groupId);
809         serviceIntent.putExtra(ContactSaveService.EXTRA_GROUP_LABEL, newLabel);
810         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_ADD, rawContactsToAdd);
811         serviceIntent.putExtra(ContactSaveService.EXTRA_RAW_CONTACTS_TO_REMOVE,
812                 rawContactsToRemove);
813 
814         // Callback intent will be invoked by the service once the group is updated
815         Intent callbackIntent = new Intent(context, callbackActivity);
816         callbackIntent.setAction(callbackAction);
817         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
818 
819         return serviceIntent;
820     }
821 
updateGroup(Intent intent)822     private void updateGroup(Intent intent) {
823         long groupId = intent.getLongExtra(EXTRA_GROUP_ID, -1);
824         String label = intent.getStringExtra(EXTRA_GROUP_LABEL);
825         long[] rawContactsToAdd = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_ADD);
826         long[] rawContactsToRemove = intent.getLongArrayExtra(EXTRA_RAW_CONTACTS_TO_REMOVE);
827 
828         if (groupId == -1) {
829             Log.e(TAG, "Invalid arguments for updateGroup request");
830             return;
831         }
832 
833         final ContentResolver resolver = getContentResolver();
834         final Uri groupUri = ContentUris.withAppendedId(Groups.CONTENT_URI, groupId);
835 
836         // Update group name if necessary
837         if (label != null) {
838             ContentValues values = new ContentValues();
839             values.put(Groups.TITLE, label);
840             resolver.update(groupUri, values, null, null);
841         }
842 
843         // Add and remove members if necessary
844         addMembersToGroup(resolver, rawContactsToAdd, groupId);
845         removeMembersFromGroup(resolver, rawContactsToRemove, groupId);
846 
847         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
848         callbackIntent.setData(groupUri);
849         deliverCallback(callbackIntent);
850     }
851 
addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd, long groupId)852     private static void addMembersToGroup(ContentResolver resolver, long[] rawContactsToAdd,
853             long groupId) {
854         if (rawContactsToAdd == null) {
855             return;
856         }
857         for (long rawContactId : rawContactsToAdd) {
858             try {
859                 final ArrayList<ContentProviderOperation> rawContactOperations =
860                         new ArrayList<ContentProviderOperation>();
861 
862                 // Build an assert operation to ensure the contact is not already in the group
863                 final ContentProviderOperation.Builder assertBuilder = ContentProviderOperation
864                         .newAssertQuery(Data.CONTENT_URI);
865                 assertBuilder.withSelection(Data.RAW_CONTACT_ID + "=? AND " +
866                         Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
867                         new String[] { String.valueOf(rawContactId),
868                         GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
869                 assertBuilder.withExpectedCount(0);
870                 rawContactOperations.add(assertBuilder.build());
871 
872                 // Build an insert operation to add the contact to the group
873                 final ContentProviderOperation.Builder insertBuilder = ContentProviderOperation
874                         .newInsert(Data.CONTENT_URI);
875                 insertBuilder.withValue(Data.RAW_CONTACT_ID, rawContactId);
876                 insertBuilder.withValue(Data.MIMETYPE, GroupMembership.CONTENT_ITEM_TYPE);
877                 insertBuilder.withValue(GroupMembership.GROUP_ROW_ID, groupId);
878                 rawContactOperations.add(insertBuilder.build());
879 
880                 if (DEBUG) {
881                     for (ContentProviderOperation operation : rawContactOperations) {
882                         Log.v(TAG, operation.toString());
883                     }
884                 }
885 
886                 // Apply batch
887                 if (!rawContactOperations.isEmpty()) {
888                     resolver.applyBatch(ContactsContract.AUTHORITY, rawContactOperations);
889                 }
890             } catch (RemoteException e) {
891                 // Something went wrong, bail without success
892                 Log.e(TAG, "Problem persisting user edits for raw contact ID " +
893                         String.valueOf(rawContactId), e);
894             } catch (OperationApplicationException e) {
895                 // The assert could have failed because the contact is already in the group,
896                 // just continue to the next contact
897                 Log.w(TAG, "Assert failed in adding raw contact ID " +
898                         String.valueOf(rawContactId) + ". Already exists in group " +
899                         String.valueOf(groupId), e);
900             }
901         }
902     }
903 
removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove, long groupId)904     private static void removeMembersFromGroup(ContentResolver resolver, long[] rawContactsToRemove,
905             long groupId) {
906         if (rawContactsToRemove == null) {
907             return;
908         }
909         for (long rawContactId : rawContactsToRemove) {
910             // Apply the delete operation on the data row for the given raw contact's
911             // membership in the given group. If no contact matches the provided selection, then
912             // nothing will be done. Just continue to the next contact.
913             resolver.delete(Data.CONTENT_URI, Data.RAW_CONTACT_ID + "=? AND " +
914                     Data.MIMETYPE + "=? AND " + GroupMembership.GROUP_ROW_ID + "=?",
915                     new String[] { String.valueOf(rawContactId),
916                     GroupMembership.CONTENT_ITEM_TYPE, String.valueOf(groupId)});
917         }
918     }
919 
920     /**
921      * Creates an intent that can be sent to this service to star or un-star a contact.
922      */
createSetStarredIntent(Context context, Uri contactUri, boolean value)923     public static Intent createSetStarredIntent(Context context, Uri contactUri, boolean value) {
924         Intent serviceIntent = new Intent(context, ContactSaveService.class);
925         serviceIntent.setAction(ContactSaveService.ACTION_SET_STARRED);
926         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
927         serviceIntent.putExtra(ContactSaveService.EXTRA_STARRED_FLAG, value);
928 
929         return serviceIntent;
930     }
931 
setStarred(Intent intent)932     private void setStarred(Intent intent) {
933         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
934         boolean value = intent.getBooleanExtra(EXTRA_STARRED_FLAG, false);
935         if (contactUri == null) {
936             Log.e(TAG, "Invalid arguments for setStarred request");
937             return;
938         }
939 
940         final ContentValues values = new ContentValues(1);
941         values.put(Contacts.STARRED, value);
942         getContentResolver().update(contactUri, values, null, null);
943 
944         // Undemote the contact if necessary
945         final Cursor c = getContentResolver().query(contactUri, new String[] {Contacts._ID},
946                 null, null, null);
947         if (c == null) {
948             return;
949         }
950         try {
951             if (c.moveToFirst()) {
952                 final long id = c.getLong(0);
953 
954                 // Don't bother undemoting if this contact is the user's profile.
955                 if (id < Profile.MIN_ID) {
956                     PinnedPositionsCompat.undemote(getContentResolver(), id);
957                 }
958             }
959         } finally {
960             c.close();
961         }
962     }
963 
964     /**
965      * Creates an intent that can be sent to this service to set the redirect to voicemail.
966      */
createSetSendToVoicemail(Context context, Uri contactUri, boolean value)967     public static Intent createSetSendToVoicemail(Context context, Uri contactUri,
968             boolean value) {
969         Intent serviceIntent = new Intent(context, ContactSaveService.class);
970         serviceIntent.setAction(ContactSaveService.ACTION_SET_SEND_TO_VOICEMAIL);
971         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
972         serviceIntent.putExtra(ContactSaveService.EXTRA_SEND_TO_VOICEMAIL_FLAG, value);
973 
974         return serviceIntent;
975     }
976 
setSendToVoicemail(Intent intent)977     private void setSendToVoicemail(Intent intent) {
978         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
979         boolean value = intent.getBooleanExtra(EXTRA_SEND_TO_VOICEMAIL_FLAG, false);
980         if (contactUri == null) {
981             Log.e(TAG, "Invalid arguments for setRedirectToVoicemail");
982             return;
983         }
984 
985         final ContentValues values = new ContentValues(1);
986         values.put(Contacts.SEND_TO_VOICEMAIL, value);
987         getContentResolver().update(contactUri, values, null, null);
988     }
989 
990     /**
991      * Creates an intent that can be sent to this service to save the contact's ringtone.
992      */
createSetRingtone(Context context, Uri contactUri, String value)993     public static Intent createSetRingtone(Context context, Uri contactUri,
994             String value) {
995         Intent serviceIntent = new Intent(context, ContactSaveService.class);
996         serviceIntent.setAction(ContactSaveService.ACTION_SET_RINGTONE);
997         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
998         serviceIntent.putExtra(ContactSaveService.EXTRA_CUSTOM_RINGTONE, value);
999 
1000         return serviceIntent;
1001     }
1002 
setRingtone(Intent intent)1003     private void setRingtone(Intent intent) {
1004         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1005         String value = intent.getStringExtra(EXTRA_CUSTOM_RINGTONE);
1006         if (contactUri == null) {
1007             Log.e(TAG, "Invalid arguments for setRingtone");
1008             return;
1009         }
1010         ContentValues values = new ContentValues(1);
1011         values.put(Contacts.CUSTOM_RINGTONE, value);
1012         getContentResolver().update(contactUri, values, null, null);
1013     }
1014 
1015     /**
1016      * Creates an intent that sets the selected data item as super primary (default)
1017      */
createSetSuperPrimaryIntent(Context context, long dataId)1018     public static Intent createSetSuperPrimaryIntent(Context context, long dataId) {
1019         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1020         serviceIntent.setAction(ContactSaveService.ACTION_SET_SUPER_PRIMARY);
1021         serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
1022         return serviceIntent;
1023     }
1024 
setSuperPrimary(Intent intent)1025     private void setSuperPrimary(Intent intent) {
1026         long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
1027         if (dataId == -1) {
1028             Log.e(TAG, "Invalid arguments for setSuperPrimary request");
1029             return;
1030         }
1031 
1032         ContactUpdateUtils.setSuperPrimary(this, dataId);
1033     }
1034 
1035     /**
1036      * Creates an intent that clears the primary flag of all data items that belong to the same
1037      * raw_contact as the given data item. Will only clear, if the data item was primary before
1038      * this call
1039      */
createClearPrimaryIntent(Context context, long dataId)1040     public static Intent createClearPrimaryIntent(Context context, long dataId) {
1041         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1042         serviceIntent.setAction(ContactSaveService.ACTION_CLEAR_PRIMARY);
1043         serviceIntent.putExtra(ContactSaveService.EXTRA_DATA_ID, dataId);
1044         return serviceIntent;
1045     }
1046 
clearPrimary(Intent intent)1047     private void clearPrimary(Intent intent) {
1048         long dataId = intent.getLongExtra(EXTRA_DATA_ID, -1);
1049         if (dataId == -1) {
1050             Log.e(TAG, "Invalid arguments for clearPrimary request");
1051             return;
1052         }
1053 
1054         // Update the primary values in the data record.
1055         ContentValues values = new ContentValues(1);
1056         values.put(Data.IS_SUPER_PRIMARY, 0);
1057         values.put(Data.IS_PRIMARY, 0);
1058 
1059         getContentResolver().update(ContentUris.withAppendedId(Data.CONTENT_URI, dataId),
1060                 values, null, null);
1061     }
1062 
1063     /**
1064      * Creates an intent that can be sent to this service to delete a contact.
1065      */
createDeleteContactIntent(Context context, Uri contactUri)1066     public static Intent createDeleteContactIntent(Context context, Uri contactUri) {
1067         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1068         serviceIntent.setAction(ContactSaveService.ACTION_DELETE_CONTACT);
1069         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_URI, contactUri);
1070         return serviceIntent;
1071     }
1072 
1073     /**
1074      * Creates an intent that can be sent to this service to delete multiple contacts.
1075      */
createDeleteMultipleContactsIntent(Context context, long[] contactIds)1076     public static Intent createDeleteMultipleContactsIntent(Context context,
1077             long[] contactIds) {
1078         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1079         serviceIntent.setAction(ContactSaveService.ACTION_DELETE_MULTIPLE_CONTACTS);
1080         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
1081         return serviceIntent;
1082     }
1083 
deleteContact(Intent intent)1084     private void deleteContact(Intent intent) {
1085         Uri contactUri = intent.getParcelableExtra(EXTRA_CONTACT_URI);
1086         if (contactUri == null) {
1087             Log.e(TAG, "Invalid arguments for deleteContact request");
1088             return;
1089         }
1090 
1091         getContentResolver().delete(contactUri, null, null);
1092     }
1093 
deleteMultipleContacts(Intent intent)1094     private void deleteMultipleContacts(Intent intent) {
1095         final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
1096         if (contactIds == null) {
1097             Log.e(TAG, "Invalid arguments for deleteMultipleContacts request");
1098             return;
1099         }
1100         for (long contactId : contactIds) {
1101             final Uri contactUri = ContentUris.withAppendedId(Contacts.CONTENT_URI, contactId);
1102             getContentResolver().delete(contactUri, null, null);
1103         }
1104         final String deleteToastMessage = getResources().getQuantityString(R.plurals
1105                 .contacts_deleted_toast, contactIds.length);
1106         mMainHandler.post(new Runnable() {
1107             @Override
1108             public void run() {
1109                 Toast.makeText(ContactSaveService.this, deleteToastMessage, Toast.LENGTH_LONG)
1110                         .show();
1111             }
1112         });
1113     }
1114 
1115     /**
1116      * Creates an intent that can be sent to this service to join two contacts.
1117      * The resulting contact uses the name from {@param contactId1} if possible.
1118      */
createJoinContactsIntent(Context context, long contactId1, long contactId2, Class<? extends Activity> callbackActivity, String callbackAction)1119     public static Intent createJoinContactsIntent(Context context, long contactId1,
1120             long contactId2, Class<? extends Activity> callbackActivity, String callbackAction) {
1121         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1122         serviceIntent.setAction(ContactSaveService.ACTION_JOIN_CONTACTS);
1123         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID1, contactId1);
1124         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_ID2, contactId2);
1125 
1126         // Callback intent will be invoked by the service once the contacts are joined.
1127         Intent callbackIntent = new Intent(context, callbackActivity);
1128         callbackIntent.setAction(callbackAction);
1129         serviceIntent.putExtra(ContactSaveService.EXTRA_CALLBACK_INTENT, callbackIntent);
1130 
1131         return serviceIntent;
1132     }
1133 
1134     /**
1135      * Creates an intent to join all raw contacts inside {@param contactIds}'s contacts.
1136      * No special attention is paid to where the resulting contact's name is taken from.
1137      */
createJoinSeveralContactsIntent(Context context, long[] contactIds)1138     public static Intent createJoinSeveralContactsIntent(Context context, long[] contactIds) {
1139         Intent serviceIntent = new Intent(context, ContactSaveService.class);
1140         serviceIntent.setAction(ContactSaveService.ACTION_JOIN_SEVERAL_CONTACTS);
1141         serviceIntent.putExtra(ContactSaveService.EXTRA_CONTACT_IDS, contactIds);
1142         return serviceIntent;
1143     }
1144 
1145 
1146     private interface JoinContactQuery {
1147         String[] PROJECTION = {
1148                 RawContacts._ID,
1149                 RawContacts.CONTACT_ID,
1150                 RawContacts.DISPLAY_NAME_SOURCE,
1151         };
1152 
1153         int _ID = 0;
1154         int CONTACT_ID = 1;
1155         int DISPLAY_NAME_SOURCE = 2;
1156     }
1157 
1158     private interface ContactEntityQuery {
1159         String[] PROJECTION = {
1160                 Contacts.Entity.DATA_ID,
1161                 Contacts.Entity.CONTACT_ID,
1162                 Contacts.Entity.IS_SUPER_PRIMARY,
1163         };
1164         String SELECTION = Data.MIMETYPE + " = '" + StructuredName.CONTENT_ITEM_TYPE + "'" +
1165                 " AND " + StructuredName.DISPLAY_NAME + "=" + Contacts.DISPLAY_NAME +
1166                 " AND " + StructuredName.DISPLAY_NAME + " IS NOT NULL " +
1167                 " AND " + StructuredName.DISPLAY_NAME + " != '' ";
1168 
1169         int DATA_ID = 0;
1170         int CONTACT_ID = 1;
1171         int IS_SUPER_PRIMARY = 2;
1172     }
1173 
joinSeveralContacts(Intent intent)1174     private void joinSeveralContacts(Intent intent) {
1175         final long[] contactIds = intent.getLongArrayExtra(EXTRA_CONTACT_IDS);
1176 
1177         // Load raw contact IDs for all contacts involved.
1178         long rawContactIds[] = getRawContactIdsForAggregation(contactIds);
1179         if (rawContactIds == null) {
1180             Log.e(TAG, "Invalid arguments for joinSeveralContacts request");
1181             return;
1182         }
1183 
1184         // For each pair of raw contacts, insert an aggregation exception
1185         final ContentResolver resolver = getContentResolver();
1186         // The maximum number of operations per batch (aka yield point) is 500. See b/22480225
1187         final int batchSize = MAX_CONTACTS_PROVIDER_BATCH_SIZE;
1188         final ArrayList<ContentProviderOperation> operations = new ArrayList<>(batchSize);
1189         for (int i = 0; i < rawContactIds.length; i++) {
1190             for (int j = 0; j < rawContactIds.length; j++) {
1191                 if (i != j) {
1192                     buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
1193                 }
1194                 // Before we get to 500 we need to flush the operations list
1195                 if (operations.size() > 0 && operations.size() % batchSize == 0) {
1196                     if (!applyJoinOperations(resolver, operations)) {
1197                         return;
1198                     }
1199                     operations.clear();
1200                 }
1201             }
1202         }
1203         if (operations.size() > 0 && !applyJoinOperations(resolver, operations)) {
1204             return;
1205         }
1206         showToast(R.string.contactsJoinedMessage);
1207     }
1208 
1209     /** Returns true if the batch was successfully applied and false otherwise. */
applyJoinOperations(ContentResolver resolver, ArrayList<ContentProviderOperation> operations)1210     private boolean applyJoinOperations(ContentResolver resolver,
1211             ArrayList<ContentProviderOperation> operations) {
1212         try {
1213             resolver.applyBatch(ContactsContract.AUTHORITY, operations);
1214             return true;
1215         } catch (RemoteException | OperationApplicationException e) {
1216             Log.e(TAG, "Failed to apply aggregation exception batch", e);
1217             showToast(R.string.contactSavedErrorToast);
1218             return false;
1219         }
1220     }
1221 
1222 
joinContacts(Intent intent)1223     private void joinContacts(Intent intent) {
1224         long contactId1 = intent.getLongExtra(EXTRA_CONTACT_ID1, -1);
1225         long contactId2 = intent.getLongExtra(EXTRA_CONTACT_ID2, -1);
1226 
1227         // Load raw contact IDs for all raw contacts involved - currently edited and selected
1228         // in the join UIs.
1229         long rawContactIds[] = getRawContactIdsForAggregation(contactId1, contactId2);
1230         if (rawContactIds == null) {
1231             Log.e(TAG, "Invalid arguments for joinContacts request");
1232             return;
1233         }
1234 
1235         ArrayList<ContentProviderOperation> operations = new ArrayList<ContentProviderOperation>();
1236 
1237         // For each pair of raw contacts, insert an aggregation exception
1238         for (int i = 0; i < rawContactIds.length; i++) {
1239             for (int j = 0; j < rawContactIds.length; j++) {
1240                 if (i != j) {
1241                     buildJoinContactDiff(operations, rawContactIds[i], rawContactIds[j]);
1242                 }
1243             }
1244         }
1245 
1246         final ContentResolver resolver = getContentResolver();
1247 
1248         // Use the name for contactId1 as the name for the newly aggregated contact.
1249         final Uri contactId1Uri = ContentUris.withAppendedId(
1250                 Contacts.CONTENT_URI, contactId1);
1251         final Uri entityUri = Uri.withAppendedPath(
1252                 contactId1Uri, Contacts.Entity.CONTENT_DIRECTORY);
1253         Cursor c = resolver.query(entityUri,
1254                 ContactEntityQuery.PROJECTION, ContactEntityQuery.SELECTION, null, null);
1255         if (c == null) {
1256             Log.e(TAG, "Unable to open Contacts DB cursor");
1257             showToast(R.string.contactSavedErrorToast);
1258             return;
1259         }
1260         long dataIdToAddSuperPrimary = -1;
1261         try {
1262             if (c.moveToFirst()) {
1263                 dataIdToAddSuperPrimary = c.getLong(ContactEntityQuery.DATA_ID);
1264             }
1265         } finally {
1266             c.close();
1267         }
1268 
1269         // Mark the name from contactId1 IS_SUPER_PRIMARY to make sure that the contact
1270         // display name does not change as a result of the join.
1271         if (dataIdToAddSuperPrimary != -1) {
1272             Builder builder = ContentProviderOperation.newUpdate(
1273                     ContentUris.withAppendedId(Data.CONTENT_URI, dataIdToAddSuperPrimary));
1274             builder.withValue(Data.IS_SUPER_PRIMARY, 1);
1275             builder.withValue(Data.IS_PRIMARY, 1);
1276             operations.add(builder.build());
1277         }
1278 
1279         boolean success = false;
1280         // Apply all aggregation exceptions as one batch
1281         try {
1282             resolver.applyBatch(ContactsContract.AUTHORITY, operations);
1283             showToast(R.string.contactsJoinedMessage);
1284             success = true;
1285         } catch (RemoteException | OperationApplicationException e) {
1286             Log.e(TAG, "Failed to apply aggregation exception batch", e);
1287             showToast(R.string.contactSavedErrorToast);
1288         }
1289 
1290         Intent callbackIntent = intent.getParcelableExtra(EXTRA_CALLBACK_INTENT);
1291         if (success) {
1292             Uri uri = RawContacts.getContactLookupUri(resolver,
1293                     ContentUris.withAppendedId(RawContacts.CONTENT_URI, rawContactIds[0]));
1294             callbackIntent.setData(uri);
1295         }
1296         deliverCallback(callbackIntent);
1297     }
1298 
getRawContactIdsForAggregation(long[] contactIds)1299     private long[] getRawContactIdsForAggregation(long[] contactIds) {
1300         if (contactIds == null) {
1301             return null;
1302         }
1303 
1304         final ContentResolver resolver = getContentResolver();
1305         long rawContactIds[];
1306 
1307         final StringBuilder queryBuilder = new StringBuilder();
1308         final String stringContactIds[] = new String[contactIds.length];
1309         for (int i = 0; i < contactIds.length; i++) {
1310             queryBuilder.append(RawContacts.CONTACT_ID + "=?");
1311             stringContactIds[i] = String.valueOf(contactIds[i]);
1312             if (contactIds[i] == -1) {
1313                 return null;
1314             }
1315             if (i == contactIds.length -1) {
1316                 break;
1317             }
1318             queryBuilder.append(" OR ");
1319         }
1320 
1321         final Cursor c = resolver.query(RawContacts.CONTENT_URI,
1322                 JoinContactQuery.PROJECTION,
1323                 queryBuilder.toString(),
1324                 stringContactIds, null);
1325         if (c == null) {
1326             Log.e(TAG, "Unable to open Contacts DB cursor");
1327             showToast(R.string.contactSavedErrorToast);
1328             return null;
1329         }
1330         try {
1331             if (c.getCount() < 2) {
1332                 Log.e(TAG, "Not enough raw contacts to aggregate together.");
1333                 return null;
1334             }
1335             rawContactIds = new long[c.getCount()];
1336             for (int i = 0; i < rawContactIds.length; i++) {
1337                 c.moveToPosition(i);
1338                 long rawContactId = c.getLong(JoinContactQuery._ID);
1339                 rawContactIds[i] = rawContactId;
1340             }
1341         } finally {
1342             c.close();
1343         }
1344         return rawContactIds;
1345     }
1346 
getRawContactIdsForAggregation(long contactId1, long contactId2)1347     private long[] getRawContactIdsForAggregation(long contactId1, long contactId2) {
1348         return getRawContactIdsForAggregation(new long[] {contactId1, contactId2});
1349     }
1350 
1351     /**
1352      * Construct a {@link AggregationExceptions#TYPE_KEEP_TOGETHER} ContentProviderOperation.
1353      */
buildJoinContactDiff(ArrayList<ContentProviderOperation> operations, long rawContactId1, long rawContactId2)1354     private void buildJoinContactDiff(ArrayList<ContentProviderOperation> operations,
1355             long rawContactId1, long rawContactId2) {
1356         Builder builder =
1357                 ContentProviderOperation.newUpdate(AggregationExceptions.CONTENT_URI);
1358         builder.withValue(AggregationExceptions.TYPE, AggregationExceptions.TYPE_KEEP_TOGETHER);
1359         builder.withValue(AggregationExceptions.RAW_CONTACT_ID1, rawContactId1);
1360         builder.withValue(AggregationExceptions.RAW_CONTACT_ID2, rawContactId2);
1361         operations.add(builder.build());
1362     }
1363 
1364     /**
1365      * Shows a toast on the UI thread.
1366      */
showToast(final int message)1367     private void showToast(final int message) {
1368         mMainHandler.post(new Runnable() {
1369 
1370             @Override
1371             public void run() {
1372                 Toast.makeText(ContactSaveService.this, message, Toast.LENGTH_LONG).show();
1373             }
1374         });
1375     }
1376 
deliverCallback(final Intent callbackIntent)1377     private void deliverCallback(final Intent callbackIntent) {
1378         mMainHandler.post(new Runnable() {
1379 
1380             @Override
1381             public void run() {
1382                 deliverCallbackOnUiThread(callbackIntent);
1383             }
1384         });
1385     }
1386 
deliverCallbackOnUiThread(final Intent callbackIntent)1387     void deliverCallbackOnUiThread(final Intent callbackIntent) {
1388         // TODO: this assumes that if there are multiple instances of the same
1389         // activity registered, the last one registered is the one waiting for
1390         // the callback. Validity of this assumption needs to be verified.
1391         for (Listener listener : sListeners) {
1392             if (callbackIntent.getComponent().equals(
1393                     ((Activity) listener).getIntent().getComponent())) {
1394                 listener.onServiceCompleted(callbackIntent);
1395                 return;
1396             }
1397         }
1398     }
1399 }
1400