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