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